portable-agent-layer 0.92.0 → 0.94.0
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/README.md +3 -1
- package/assets/schema/pal-settings.schema.json +12 -0
- package/assets/skills/research-entity/SKILL.md +57 -0
- package/assets/templates/pal-settings.json +3 -0
- package/package.json +1 -1
- package/src/cli/infer.ts +6 -0
- package/src/cli/knowledge-research.ts +93 -0
- package/src/cli/knowledge.ts +110 -0
- package/src/hooks/handlers/entity-extraction.ts +92 -0
- package/src/hooks/handlers/inject-retrieval.ts +4 -0
- package/src/hooks/lib/daily-nudge.ts +29 -6
- package/src/hooks/lib/entity-cards.ts +112 -0
- package/src/hooks/lib/entity-extraction.ts +356 -0
- package/src/hooks/lib/entity-names.ts +242 -0
- package/src/hooks/lib/entity-research.ts +305 -0
- package/src/hooks/lib/entity-review.ts +101 -0
- package/src/hooks/lib/inference.ts +41 -6
- package/src/hooks/lib/stop.ts +19 -0
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/knowledge/ingest.ts +25 -8
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning what the user said about people and companies into knowledge-store
|
|
3
|
+
* writes. A clear match or a new name is written; a name that could be more than
|
|
4
|
+
* one known entity waits in the review queue instead of being guessed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import type {
|
|
10
|
+
CompanyInput,
|
|
11
|
+
IngestInput,
|
|
12
|
+
PersonInput,
|
|
13
|
+
} from "../../tools/knowledge/ingest";
|
|
14
|
+
import {
|
|
15
|
+
findMentions,
|
|
16
|
+
foldName,
|
|
17
|
+
indexedEntities,
|
|
18
|
+
isExcluded,
|
|
19
|
+
lookupName,
|
|
20
|
+
type NamedEntity,
|
|
21
|
+
type NameIndex,
|
|
22
|
+
proseOnly,
|
|
23
|
+
} from "./entity-names";
|
|
24
|
+
import { paths } from "./paths";
|
|
25
|
+
import { isSystemText, stripInjectedTags } from "./prompt-text";
|
|
26
|
+
|
|
27
|
+
export interface ExtractedEntity {
|
|
28
|
+
kind: "person" | "company";
|
|
29
|
+
name: string;
|
|
30
|
+
existing: string;
|
|
31
|
+
aliases: string[];
|
|
32
|
+
role: string;
|
|
33
|
+
organization: string;
|
|
34
|
+
relation: string;
|
|
35
|
+
fact: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResearchProfile {
|
|
39
|
+
match: "sure" | "unsure" | "none";
|
|
40
|
+
summary: string;
|
|
41
|
+
role: string;
|
|
42
|
+
organization: string;
|
|
43
|
+
website: string;
|
|
44
|
+
linkedin: string;
|
|
45
|
+
socials: { platform: string; url: string }[];
|
|
46
|
+
registry: {
|
|
47
|
+
name: string;
|
|
48
|
+
number: string;
|
|
49
|
+
taxNumber: string;
|
|
50
|
+
seat: string;
|
|
51
|
+
status: string;
|
|
52
|
+
managers: string[];
|
|
53
|
+
url: string;
|
|
54
|
+
};
|
|
55
|
+
news: { date: string; title: string; url: string }[];
|
|
56
|
+
sources: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReviewItem {
|
|
60
|
+
ts: string;
|
|
61
|
+
source: string;
|
|
62
|
+
id: string;
|
|
63
|
+
reason: "ambiguous" | "unknown-existing" | "first-name-only" | "web-profile";
|
|
64
|
+
entity: ExtractedEntity;
|
|
65
|
+
candidates: string[];
|
|
66
|
+
profile?: ResearchProfile;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ExtractionPlan {
|
|
70
|
+
ingest: IngestInput;
|
|
71
|
+
review: ReviewItem[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function closedObject<P extends Record<string, unknown>>(properties: P) {
|
|
75
|
+
return {
|
|
76
|
+
type: "object" as const,
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
properties,
|
|
79
|
+
required: Object.keys(properties),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function extractionSchema() {
|
|
84
|
+
const text = { type: "string" as const };
|
|
85
|
+
const entity = closedObject({
|
|
86
|
+
kind: { type: "string" as const, enum: ["person", "company"] },
|
|
87
|
+
name: text,
|
|
88
|
+
existing: text,
|
|
89
|
+
aliases: { type: "array" as const, items: text },
|
|
90
|
+
role: text,
|
|
91
|
+
organization: text,
|
|
92
|
+
relation: text,
|
|
93
|
+
fact: text,
|
|
94
|
+
});
|
|
95
|
+
return closedObject({ entities: { type: "array" as const, items: entity } });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MAX_TEXT = 4000;
|
|
99
|
+
const MIN_TEXT = 20;
|
|
100
|
+
const MAX_KNOWN = 120;
|
|
101
|
+
const CURSOR_KEEP = 200;
|
|
102
|
+
|
|
103
|
+
interface Message {
|
|
104
|
+
role: string;
|
|
105
|
+
content: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function userTexts(messages: Message[]): string[] {
|
|
109
|
+
return messages
|
|
110
|
+
.filter((m) => m.role === "user" && typeof m.content === "string")
|
|
111
|
+
.map((m) => stripInjectedTags(m.content as string))
|
|
112
|
+
.filter((t) => t.length > 0 && !isSystemText(t));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The user's words since the last extraction, and the new high-water mark. */
|
|
116
|
+
export function unseenUserText(
|
|
117
|
+
messages: Message[],
|
|
118
|
+
seen: number
|
|
119
|
+
): { text: string; count: number } {
|
|
120
|
+
const texts = userTexts(messages);
|
|
121
|
+
const fresh = texts.slice(Math.min(seen, texts.length));
|
|
122
|
+
const text = fresh.map((t, i) => `${i + 1}. ${proseOnly(t).trim()}`).join("\n");
|
|
123
|
+
return { text: text.slice(-MAX_TEXT), count: texts.length };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hasUnknownProperName(text: string, ignored: Set<string>): boolean {
|
|
127
|
+
const sentences = proseOnly(text).split(/[.!?\n]+/);
|
|
128
|
+
return sentences.some((s) =>
|
|
129
|
+
s
|
|
130
|
+
.trim()
|
|
131
|
+
.split(/\s+/)
|
|
132
|
+
.slice(1)
|
|
133
|
+
.some((w) => /^\p{Lu}\p{Ll}/u.test(w) && !ignored.has(foldName(w)))
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Cheap gate before a model call: a known name, or a capitalised word mid-sentence. */
|
|
138
|
+
export function worthExtracting(
|
|
139
|
+
text: string,
|
|
140
|
+
index: NameIndex,
|
|
141
|
+
excluded: string[]
|
|
142
|
+
): boolean {
|
|
143
|
+
if (proseOnly(text).trim().length < MIN_TEXT) return false;
|
|
144
|
+
if (findMentions(text, index).length > 0) return true;
|
|
145
|
+
return hasUnknownProperName(text, new Set(excluded.map(foldName)));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function knownLine(entity: NamedEntity): string {
|
|
149
|
+
const aliases =
|
|
150
|
+
entity.aliases.length > 0 ? ` (also: ${entity.aliases.join(", ")})` : "";
|
|
151
|
+
const kind = entity.domain === "People" ? "person" : "company";
|
|
152
|
+
return `${entity.slug} | ${kind} | ${entity.title}${aliases}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function extractionSystem(principal: string, assistant: string): string {
|
|
156
|
+
return [
|
|
157
|
+
`You keep a contact book for ${principal}. From ${principal}'s own messages to an AI assistant, list the people and companies ${principal} talks about as part of their work and life.`,
|
|
158
|
+
`- Never list ${principal} or the assistant (${assistant}).`,
|
|
159
|
+
`- People: individuals ${principal} deals with (colleagues, clients, partners, friends, family, contacts). Skip public figures cited only for their work.`,
|
|
160
|
+
`- Companies: only organisations ${principal} has a relationship with: employer, own company, client, prospect, partner, contractor, school. Skip tools, products, platforms, languages and vendors that are merely used or discussed.`,
|
|
161
|
+
"- existing: the slug of the matching known entry below, or empty when it is someone new.",
|
|
162
|
+
"- aliases: other names the messages use for it (nickname, short form), not the main name.",
|
|
163
|
+
`- role, organization (a person's company), relation (how ${principal} relates to it: client, partner, colleague, friend, employer, prospect, own company): only what the messages state, else empty.`,
|
|
164
|
+
"- fact: one sentence of what the messages state about it, else empty. Never guess.",
|
|
165
|
+
"- Ignore names that appear only inside pasted logs, code or email headers.",
|
|
166
|
+
"Return an empty list when nobody qualifies.",
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function extractionUser(text: string, index: NameIndex): string {
|
|
171
|
+
const known = indexedEntities(index)
|
|
172
|
+
.filter((e) => !isExcluded(e, index))
|
|
173
|
+
.slice(0, MAX_KNOWN)
|
|
174
|
+
.map(knownLine);
|
|
175
|
+
return `Known entries (slug | kind | name):\n${known.join("\n") || "(none)"}\n\nMessages:\n${text}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function domainOf(kind: ExtractedEntity["kind"]): NamedEntity["domain"] {
|
|
179
|
+
return kind === "person" ? "People" : "Companies";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function namesUser(e: ExtractedEntity, excluded: string[]): boolean {
|
|
183
|
+
const own = new Set(excluded.filter(Boolean).map(foldName));
|
|
184
|
+
return [e.name, ...e.aliases].some((n) => own.has(foldName(n)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type Resolution =
|
|
188
|
+
| { kind: "new" }
|
|
189
|
+
| { kind: "known"; entity: NamedEntity }
|
|
190
|
+
| { kind: "review"; reason: ReviewItem["reason"]; candidates: NamedEntity[] };
|
|
191
|
+
|
|
192
|
+
function resolveExisting(e: ExtractedEntity, sameDomain: NamedEntity[]): Resolution {
|
|
193
|
+
const entity = sameDomain.find((c) => c.slug === e.existing);
|
|
194
|
+
return entity
|
|
195
|
+
? { kind: "known", entity }
|
|
196
|
+
: { kind: "review", reason: "unknown-existing", candidates: [] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** "Dan" alone may be a known Daniel under a nickname the store hasn't seen yet. */
|
|
200
|
+
function newcomer(e: ExtractedEntity): Resolution {
|
|
201
|
+
const firstNameOnly = e.kind === "person" && !/\s/.test(e.name.trim());
|
|
202
|
+
return firstNameOnly
|
|
203
|
+
? { kind: "review", reason: "first-name-only", candidates: [] }
|
|
204
|
+
: { kind: "new" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveEntity(e: ExtractedEntity, index: NameIndex): Resolution {
|
|
208
|
+
const domain = domainOf(e.kind);
|
|
209
|
+
const sameDomain = indexedEntities(index).filter((c) => c.domain === domain);
|
|
210
|
+
if (e.existing) return resolveExisting(e, sameDomain);
|
|
211
|
+
const { entities, exact } = lookupName(e.name, index);
|
|
212
|
+
const candidates = entities.filter((c) => c.domain === domain);
|
|
213
|
+
if (candidates.length === 0) return newcomer(e);
|
|
214
|
+
if (exact && candidates.length === 1) return { kind: "known", entity: candidates[0] };
|
|
215
|
+
return { kind: "review", reason: "ambiguous", candidates };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function otherNames(e: ExtractedEntity, storedName: string): string[] {
|
|
219
|
+
const names = [e.name, ...e.aliases].map((n) => n.trim()).filter(Boolean);
|
|
220
|
+
return [...new Set(names)].filter((n) => foldName(n) !== foldName(storedName));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** "Acme" for a stored "Acme Kft." links the person to that file, not a new stub. */
|
|
224
|
+
function storedOrganization(organization: string, index: NameIndex): string | null {
|
|
225
|
+
if (!organization.trim()) return null;
|
|
226
|
+
const { entities, exact } = lookupName(organization, index);
|
|
227
|
+
const companies = entities.filter((c) => c.domain === "Companies");
|
|
228
|
+
return exact && companies.length === 1 ? companies[0].title : organization;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function personInput(
|
|
232
|
+
e: ExtractedEntity,
|
|
233
|
+
name: string,
|
|
234
|
+
index: NameIndex,
|
|
235
|
+
slug: string | null
|
|
236
|
+
): PersonInput {
|
|
237
|
+
return {
|
|
238
|
+
name,
|
|
239
|
+
slug,
|
|
240
|
+
role: e.role || null,
|
|
241
|
+
company: storedOrganization(e.organization, index),
|
|
242
|
+
context: e.fact || null,
|
|
243
|
+
aliases: otherNames(e, name),
|
|
244
|
+
relation: e.relation || null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function companyInput(
|
|
249
|
+
e: ExtractedEntity,
|
|
250
|
+
name: string,
|
|
251
|
+
slug: string | null
|
|
252
|
+
): CompanyInput {
|
|
253
|
+
return {
|
|
254
|
+
name,
|
|
255
|
+
slug,
|
|
256
|
+
context: e.fact || null,
|
|
257
|
+
aliases: otherNames(e, name),
|
|
258
|
+
relation: e.relation || null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Ingest input for one extracted entity: the known file it updates, or a new one. */
|
|
263
|
+
export function ingestInput(
|
|
264
|
+
e: ExtractedEntity,
|
|
265
|
+
index: NameIndex,
|
|
266
|
+
known: NamedEntity | null,
|
|
267
|
+
newName?: string
|
|
268
|
+
): IngestInput {
|
|
269
|
+
const name = known?.title ?? (newName?.trim() || e.name.trim());
|
|
270
|
+
const slug = known?.slug ?? null;
|
|
271
|
+
return e.kind === "person"
|
|
272
|
+
? { people: [personInput(e, name, index, slug)] }
|
|
273
|
+
: { companies: [companyInput(e, name, slug)] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function planExtraction(
|
|
277
|
+
extracted: ExtractedEntity[],
|
|
278
|
+
index: NameIndex,
|
|
279
|
+
excluded: string[],
|
|
280
|
+
source: string
|
|
281
|
+
): ExtractionPlan {
|
|
282
|
+
const plan: ExtractionPlan = { ingest: { people: [], companies: [] }, review: [] };
|
|
283
|
+
const queue = (
|
|
284
|
+
entity: ExtractedEntity,
|
|
285
|
+
reason: ReviewItem["reason"],
|
|
286
|
+
candidates: NamedEntity[]
|
|
287
|
+
) =>
|
|
288
|
+
plan.review.push({
|
|
289
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
290
|
+
ts: new Date().toISOString(),
|
|
291
|
+
source,
|
|
292
|
+
reason,
|
|
293
|
+
entity,
|
|
294
|
+
candidates: candidates.map((c) => c.slug).sort(),
|
|
295
|
+
});
|
|
296
|
+
for (const e of extracted) {
|
|
297
|
+
if (!e.name.trim() || namesUser(e, excluded)) continue;
|
|
298
|
+
const resolution = resolveEntity(e, index);
|
|
299
|
+
if (resolution.kind === "review") {
|
|
300
|
+
queue(e, resolution.reason, resolution.candidates);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (resolution.kind === "known" && isExcluded(resolution.entity, index)) continue;
|
|
304
|
+
const known = resolution.kind === "known" ? resolution.entity : null;
|
|
305
|
+
const input = ingestInput(e, index, known);
|
|
306
|
+
plan.ingest.people?.push(...(input.people ?? []));
|
|
307
|
+
plan.ingest.companies?.push(...(input.companies ?? []));
|
|
308
|
+
}
|
|
309
|
+
return plan;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function parseExtraction(output: string): ExtractedEntity[] {
|
|
313
|
+
try {
|
|
314
|
+
const parsed = JSON.parse(output) as { entities?: ExtractedEntity[] };
|
|
315
|
+
return Array.isArray(parsed.entities) ? parsed.entities : [];
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function cursorFile(): string {
|
|
322
|
+
return resolve(paths.state(), "entity-extraction.json");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function readCursors(): Record<string, number> {
|
|
326
|
+
const file = cursorFile();
|
|
327
|
+
if (!existsSync(file)) return {};
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(readFileSync(file, "utf-8")) as Record<string, number>;
|
|
330
|
+
} catch {
|
|
331
|
+
return {};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function seenCount(sessionId: string): number {
|
|
336
|
+
return readCursors()[sessionId] ?? 0;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function markSeen(sessionId: string, count: number): void {
|
|
340
|
+
const cursors = Object.entries({ ...readCursors(), [sessionId]: count }).slice(
|
|
341
|
+
-CURSOR_KEEP
|
|
342
|
+
);
|
|
343
|
+
writeFileSync(
|
|
344
|
+
cursorFile(),
|
|
345
|
+
`${JSON.stringify(Object.fromEntries(cursors), null, 2)}\n`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function reviewQueueFile(): string {
|
|
350
|
+
return resolve(paths.knowledge(), "_review.jsonl");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function queueForReview(items: ReviewItem[]): void {
|
|
354
|
+
if (items.length === 0) return;
|
|
355
|
+
appendFileSync(reviewQueueFile(), items.map((i) => `${JSON.stringify(i)}\n`).join(""));
|
|
356
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which known people and companies a text mentions. A lookup against the names
|
|
3
|
+
* already in the knowledge store, never a guess at what a name looks like.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface NamedEntity {
|
|
7
|
+
slug: string;
|
|
8
|
+
domain: "People" | "Companies";
|
|
9
|
+
title: string;
|
|
10
|
+
aliases: string[];
|
|
11
|
+
domainName?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Term {
|
|
15
|
+
tokens: string[];
|
|
16
|
+
entity: NamedEntity;
|
|
17
|
+
partial: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NameIndex {
|
|
21
|
+
terms: Term[];
|
|
22
|
+
excluded: Set<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Mention {
|
|
26
|
+
said: string;
|
|
27
|
+
entities: NamedEntity[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Token {
|
|
31
|
+
raw: string;
|
|
32
|
+
folded: string;
|
|
33
|
+
gapBefore: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MIN_PARTIAL = 3;
|
|
37
|
+
|
|
38
|
+
function nonProse(): RegExp[] {
|
|
39
|
+
return [
|
|
40
|
+
/```[\s\S]*?```/g,
|
|
41
|
+
/`[^`\n]*`/g,
|
|
42
|
+
/\bhttps?:\/\/\S+/g,
|
|
43
|
+
/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g,
|
|
44
|
+
/[\w.-]+(?:\/[\w.-]+)+/g,
|
|
45
|
+
/<[^>\n]*>/g,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function withoutLegalSuffix(name: string): string {
|
|
50
|
+
return name.replace(
|
|
51
|
+
/\s+(?:kft|nyrt|zrt|bt|kkt|ltd|inc|llc|gmbh|korlatolt felelossegu tarsasag)\.?$/,
|
|
52
|
+
""
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function breaksSentence(gap: string): boolean {
|
|
57
|
+
return /[.!?:;\n]/.test(gap);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function foldName(name: string): string {
|
|
61
|
+
return name
|
|
62
|
+
.normalize("NFKD")
|
|
63
|
+
.replace(/\p{M}/gu, "")
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/['’]s$/, "")
|
|
66
|
+
.replace(/[.,]+$/, "")
|
|
67
|
+
.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function proseOnly(text: string): string {
|
|
71
|
+
return nonProse().reduce((out, re) => out.replace(re, " "), text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tokenize(text: string): Token[] {
|
|
75
|
+
const tokens: Token[] = [];
|
|
76
|
+
let last = 0;
|
|
77
|
+
for (const m of text.matchAll(/[\p{L}\p{N}](?:[\p{L}\p{N}'’.-]*[\p{L}\p{N}])?/gu)) {
|
|
78
|
+
const start = m.index ?? 0;
|
|
79
|
+
tokens.push({
|
|
80
|
+
raw: m[0],
|
|
81
|
+
folded: foldName(m[0]),
|
|
82
|
+
gapBefore: text.slice(last, start),
|
|
83
|
+
});
|
|
84
|
+
last = start + m[0].length;
|
|
85
|
+
}
|
|
86
|
+
return tokens;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function words(name: string): string[] {
|
|
90
|
+
return tokenize(name).map((t) => t.folded);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function companyNames(entity: NamedEntity): string[] {
|
|
94
|
+
const bare = withoutLegalSuffix(foldName(entity.title));
|
|
95
|
+
const site = entity.domainName ? foldName(entity.domainName).split(".")[0] : "";
|
|
96
|
+
return [entity.title, bare, site, ...entity.aliases];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function personNames(entity: NamedEntity): string[] {
|
|
100
|
+
const parts = words(entity.title);
|
|
101
|
+
const reversed = parts.length === 2 ? `${parts[1]} ${parts[0]}` : "";
|
|
102
|
+
return [entity.title, reversed, ...entity.aliases];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fullTerms(entity: NamedEntity): Term[] {
|
|
106
|
+
const names = entity.domain === "People" ? personNames(entity) : companyNames(entity);
|
|
107
|
+
return names
|
|
108
|
+
.map(words)
|
|
109
|
+
.filter((tokens) => tokens.join(" ").length >= 2)
|
|
110
|
+
.map((tokens) => ({ tokens, entity, partial: false }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function partialTerms(entity: NamedEntity): Term[] {
|
|
114
|
+
if (entity.domain !== "People") return [];
|
|
115
|
+
const parts = words(entity.title);
|
|
116
|
+
if (parts.length < 2) return [];
|
|
117
|
+
return parts
|
|
118
|
+
.filter((p) => p.length >= MIN_PARTIAL)
|
|
119
|
+
.map((p) => ({ tokens: [p], entity, partial: true }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Names in `excludedNames` (the user, the assistant) are indexed so they consume
|
|
123
|
+
* their words, but never reported. */
|
|
124
|
+
export function buildNameIndex(
|
|
125
|
+
entities: NamedEntity[],
|
|
126
|
+
excludedNames: string[]
|
|
127
|
+
): NameIndex {
|
|
128
|
+
const excludedFolded = new Set(excludedNames.filter(Boolean).map(foldName));
|
|
129
|
+
const excluded = new Set(
|
|
130
|
+
entities
|
|
131
|
+
.filter((e) => fullTerms(e).some((t) => excludedFolded.has(t.tokens.join(" "))))
|
|
132
|
+
.map(entityKey)
|
|
133
|
+
);
|
|
134
|
+
const terms = entities.flatMap((e) => [...fullTerms(e), ...partialTerms(e)]);
|
|
135
|
+
terms.sort(
|
|
136
|
+
(a, b) => Number(a.partial) - Number(b.partial) || b.tokens.length - a.tokens.length
|
|
137
|
+
);
|
|
138
|
+
return { terms, excluded };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function matchesAt(tokens: Token[], at: number, term: Term): boolean {
|
|
142
|
+
return term.tokens.every((w, i) => tokens[at + i]?.folded === w);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isCapitalized(token: Token | undefined): boolean {
|
|
146
|
+
return Boolean(token && /^\p{Lu}/u.test(token.raw));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function joinedTo(token: Token | undefined): boolean {
|
|
150
|
+
return Boolean(
|
|
151
|
+
token && /^\s+$/.test(token.gapBefore) && !breaksSentence(token.gapBefore)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function startsSentence(tokens: Token[], at: number): boolean {
|
|
156
|
+
return at === 0 || breaksSentence(tokens[at].gapBefore);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** "Daniel Miessler" is not a known Dániel: a lone given name next to an unknown
|
|
160
|
+
* capitalised word belongs to someone else. */
|
|
161
|
+
function besideUnknownName(tokens: Token[], at: number): boolean {
|
|
162
|
+
const next = tokens[at + 1];
|
|
163
|
+
if (joinedTo(next) && isCapitalized(next)) return true;
|
|
164
|
+
const prev = tokens[at - 1];
|
|
165
|
+
return joinedTo(tokens[at]) && isCapitalized(prev) && !startsSentence(tokens, at - 1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isLoneWordOfPerson(term: Term): boolean {
|
|
169
|
+
return term.entity.domain === "People" && term.tokens.length === 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function termsAt(index: NameIndex, tokens: Token[], at: number): Term[] {
|
|
173
|
+
const first = index.terms.find((t) => matchesAt(tokens, at, t));
|
|
174
|
+
if (!first) return [];
|
|
175
|
+
if (isLoneWordOfPerson(first) && besideUnknownName(tokens, at)) return [];
|
|
176
|
+
return index.terms.filter(
|
|
177
|
+
(t) =>
|
|
178
|
+
t.partial === first.partial &&
|
|
179
|
+
t.tokens.length === first.tokens.length &&
|
|
180
|
+
matchesAt(tokens, at, t)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function addMention(
|
|
185
|
+
found: Map<string, Mention>,
|
|
186
|
+
said: string,
|
|
187
|
+
terms: Term[],
|
|
188
|
+
index: NameIndex
|
|
189
|
+
) {
|
|
190
|
+
const entities = distinctEntities(terms);
|
|
191
|
+
if (entities.some((e) => isExcluded(e, index))) return;
|
|
192
|
+
const key = entities.map(entityKey).sort().join("|");
|
|
193
|
+
if (!found.has(key)) found.set(key, { said, entities });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function findMentions(text: string, index: NameIndex): Mention[] {
|
|
197
|
+
const tokens = tokenize(proseOnly(text));
|
|
198
|
+
const found = new Map<string, Mention>();
|
|
199
|
+
let at = 0;
|
|
200
|
+
while (at < tokens.length) {
|
|
201
|
+
const terms = termsAt(index, tokens, at);
|
|
202
|
+
const width = terms[0]?.tokens.length ?? 1;
|
|
203
|
+
if (terms.length > 0) {
|
|
204
|
+
const said = tokens
|
|
205
|
+
.slice(at, at + width)
|
|
206
|
+
.map((t) => t.raw)
|
|
207
|
+
.join(" ");
|
|
208
|
+
addMention(found, said, terms, index);
|
|
209
|
+
}
|
|
210
|
+
at += width;
|
|
211
|
+
}
|
|
212
|
+
return [...found.values()];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The entities a name could mean; `exact` when it is someone's full name or alias
|
|
216
|
+
* rather than one word of a longer name. */
|
|
217
|
+
export function lookupName(
|
|
218
|
+
name: string,
|
|
219
|
+
index: NameIndex
|
|
220
|
+
): { entities: NamedEntity[]; exact: boolean } {
|
|
221
|
+
const folded = words(name).join(" ");
|
|
222
|
+
const named = (t: Term) => t.tokens.join(" ") === folded;
|
|
223
|
+
const full = index.terms.filter((t) => !t.partial && named(t));
|
|
224
|
+
if (full.length > 0) return { entities: distinctEntities(full), exact: true };
|
|
225
|
+
return { entities: distinctEntities(index.terms.filter(named)), exact: false };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function indexedEntities(index: NameIndex): NamedEntity[] {
|
|
229
|
+
return distinctEntities(index.terms);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function isExcluded(entity: NamedEntity, index: NameIndex): boolean {
|
|
233
|
+
return index.excluded.has(entityKey(entity));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function entityKey(entity: { domain: string; slug: string }): string {
|
|
237
|
+
return `${entity.domain}/${entity.slug}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function distinctEntities(terms: Term[]): NamedEntity[] {
|
|
241
|
+
return [...new Map(terms.map((t) => [entityKey(t.entity), t.entity])).values()];
|
|
242
|
+
}
|