@littlebigbrain/create 0.1.0 → 0.3.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 +6 -2
- package/lib/create.mjs +33 -5
- package/package.json +1 -1
- package/templates/_shared/testing.ts +53 -9
- package/templates/documents-link/README.md +9 -7
- package/templates/documents-link/workflow.test.ts +28 -1
- package/templates/documents-link/workflow.ts +24 -14
- package/templates/documents-map/README.md +212 -0
- package/templates/documents-map/map.ts +1135 -0
- package/templates/documents-map/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-map/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-map/samples/expected.json +50 -0
- package/templates/documents-map/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-map/samples/raumblaetter.pdf +109 -0
- package/templates/documents-map/samples/trace.json +613 -0
- package/templates/documents-map/samples.ts +182 -0
- package/templates/documents-map/workflow.test.ts +770 -0
- package/templates/documents-map/workflow.ts +1199 -0
- package/templates/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- package/templates/ontology-fit/workflow.test.ts +7 -1
- package/templates/ontology-fit/workflow.ts +5 -2
- package/templates/review/README.md +158 -0
- package/templates/review/decision.ts +483 -0
- package/templates/review/samples/expected.json +37 -0
- package/templates/review/samples/proposals.json +35 -0
- package/templates/review/samples.ts +154 -0
- package/templates/review/workflow.test.ts +438 -0
- package/templates/review/workflow.ts +459 -0
- package/templates/values-extract/workflow.test.ts +24 -1
- package/templates/values-extract/workflow.ts +11 -3
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
// A turn of a source shard (general linking 8.1, 8.2): a window of records
|
|
2
|
+
// of one class. Its steps:
|
|
3
|
+
//
|
|
4
|
+
// 1. `sources`: the records of the window, read at the message's commit,
|
|
5
|
+
// and the mentions the workflow wrote for them before;
|
|
6
|
+
// 2. `mentions-<k>`: the mentions of record k: its names and keys, and the
|
|
7
|
+
// spans of its text fields (the gazetteer pass, then the spans written
|
|
8
|
+
// like a name or a key; the reasoning model only for a field that opts in);
|
|
9
|
+
// 3. `link-<n>`: the candidates of 40 mentions and their decisions;
|
|
10
|
+
// 4. `write-<k>`: one owned write per record: its mentions and their keys;
|
|
11
|
+
// 5. `send`: the `decided` message to the things identity.
|
|
12
|
+
//
|
|
13
|
+
// Each step saves what the next steps need, so a turn that runs again after
|
|
14
|
+
// a crash reads and asks nothing twice.
|
|
15
|
+
import {
|
|
16
|
+
ENTITY_ID_KEY_SENTINEL,
|
|
17
|
+
entityRef,
|
|
18
|
+
isActivityRefused,
|
|
19
|
+
splitInHalves,
|
|
20
|
+
type DecideAnswer,
|
|
21
|
+
type DecideRequest,
|
|
22
|
+
type LbbActivities,
|
|
23
|
+
type OwnedWrite,
|
|
24
|
+
type WorkflowContext,
|
|
25
|
+
type WorkflowStepContext,
|
|
26
|
+
} from "@littlebigbrain/client";
|
|
27
|
+
import {
|
|
28
|
+
BOUNDS,
|
|
29
|
+
candidatesFor,
|
|
30
|
+
hopQuery,
|
|
31
|
+
keyCountsQuery,
|
|
32
|
+
keyHitsQuery,
|
|
33
|
+
neighbourMentionsQuery,
|
|
34
|
+
neighbourPaths,
|
|
35
|
+
parseCounts,
|
|
36
|
+
parseHits,
|
|
37
|
+
parseHops,
|
|
38
|
+
parseNeighbourMentions,
|
|
39
|
+
stopKeys,
|
|
40
|
+
type Candidate,
|
|
41
|
+
type Hop,
|
|
42
|
+
type KeyHit,
|
|
43
|
+
type NeighbourMention,
|
|
44
|
+
} from "./candidates.js";
|
|
45
|
+
import {
|
|
46
|
+
decideRequest,
|
|
47
|
+
effectIdOf,
|
|
48
|
+
linkRule,
|
|
49
|
+
none,
|
|
50
|
+
packRequests,
|
|
51
|
+
rulesDecisions,
|
|
52
|
+
sameRecord,
|
|
53
|
+
sourceState,
|
|
54
|
+
type Asked,
|
|
55
|
+
type AskedPair,
|
|
56
|
+
type Decision,
|
|
57
|
+
type RecordContext,
|
|
58
|
+
} from "./decide.js";
|
|
59
|
+
import { blockKeys, keptKeys, normalName, type SpanKind } from "./names.js";
|
|
60
|
+
import {
|
|
61
|
+
MENTION_FIELDS,
|
|
62
|
+
orphanMentionsQuery,
|
|
63
|
+
parseSourceMentions,
|
|
64
|
+
parseSources,
|
|
65
|
+
parts,
|
|
66
|
+
sourceMentionsQuery,
|
|
67
|
+
sourcesQuery,
|
|
68
|
+
type WrittenMention,
|
|
69
|
+
} from "./reads.js";
|
|
70
|
+
import {
|
|
71
|
+
DECIDED_BYTES,
|
|
72
|
+
classOf,
|
|
73
|
+
idIn,
|
|
74
|
+
reviewsWorkflowName,
|
|
75
|
+
thingsId,
|
|
76
|
+
type DecidedMessage,
|
|
77
|
+
type LinkPorts,
|
|
78
|
+
type Settings,
|
|
79
|
+
type SourceMessage,
|
|
80
|
+
} from "./settings.js";
|
|
81
|
+
import {
|
|
82
|
+
checkExtracted,
|
|
83
|
+
extractRequest,
|
|
84
|
+
extracts,
|
|
85
|
+
gazetteerKeys,
|
|
86
|
+
roleOf,
|
|
87
|
+
selfMentions,
|
|
88
|
+
textMentions,
|
|
89
|
+
type FieldSpec,
|
|
90
|
+
type FoundMention,
|
|
91
|
+
type SourceRecord,
|
|
92
|
+
} from "./sources/records.js";
|
|
93
|
+
import { mentionIri, type IdentityPair } from "./things.js";
|
|
94
|
+
|
|
95
|
+
/** What a source shard has done, over all its turns. */
|
|
96
|
+
export interface ShardState {
|
|
97
|
+
turns: number;
|
|
98
|
+
sources: number;
|
|
99
|
+
mentions: number;
|
|
100
|
+
links: number;
|
|
101
|
+
review: number;
|
|
102
|
+
nolink: number;
|
|
103
|
+
alone: number;
|
|
104
|
+
identities: number;
|
|
105
|
+
/** The stop keys that fired, with how many mentions held each (at most 50 keys). */
|
|
106
|
+
stopKeys: Record<string, number>;
|
|
107
|
+
/** How often each bound cut a list. */
|
|
108
|
+
bounds: Record<string, number>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const emptyShard = (): ShardState => ({
|
|
112
|
+
turns: 0,
|
|
113
|
+
sources: 0,
|
|
114
|
+
mentions: 0,
|
|
115
|
+
links: 0,
|
|
116
|
+
review: 0,
|
|
117
|
+
nolink: 0,
|
|
118
|
+
alone: 0,
|
|
119
|
+
identities: 0,
|
|
120
|
+
stopKeys: {},
|
|
121
|
+
bounds: {},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** A mention of the turn, with its IRI and keys. */
|
|
125
|
+
export interface SourceMention extends FoundMention {
|
|
126
|
+
iri: string;
|
|
127
|
+
source: string;
|
|
128
|
+
record: string | null;
|
|
129
|
+
keys: string[];
|
|
130
|
+
cannot: string[];
|
|
131
|
+
thing: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Mentions of one `link-<n>` step. */
|
|
135
|
+
export const LINK_STEP = 40;
|
|
136
|
+
/** Stop keys the state keeps. */
|
|
137
|
+
const MAX_STOP_KEYS = 50;
|
|
138
|
+
|
|
139
|
+
const strong = { consistency: "strong" as const };
|
|
140
|
+
|
|
141
|
+
/** The text fields of a class, and its context fields. */
|
|
142
|
+
const fieldsOf = (fields: Record<string, FieldSpec>, role: (spec: FieldSpec) => boolean) =>
|
|
143
|
+
Object.entries(fields)
|
|
144
|
+
.filter(([, spec]) => role(spec))
|
|
145
|
+
.map(([field]) => field);
|
|
146
|
+
|
|
147
|
+
/** A source turn, or a turn of the reviews workflow. */
|
|
148
|
+
export async function sourceTurn(
|
|
149
|
+
ctx: WorkflowContext,
|
|
150
|
+
lbb: LbbActivities,
|
|
151
|
+
ports: LinkPorts,
|
|
152
|
+
settings: Settings,
|
|
153
|
+
message: SourceMessage,
|
|
154
|
+
state: ShardState,
|
|
155
|
+
): Promise<{ state: ShardState; next?: SourceMessage }> {
|
|
156
|
+
if (message.workflow === reviewsWorkflowName(settings)) {
|
|
157
|
+
await reviewsTurn(ctx, ports, settings, message);
|
|
158
|
+
return { state: { ...state, turns: state.turns + 1 } };
|
|
159
|
+
}
|
|
160
|
+
const cls = classOf(settings, message.workflow);
|
|
161
|
+
const fields = settings.fields[cls]!;
|
|
162
|
+
const read = reader(lbb, message, cls, fields);
|
|
163
|
+
|
|
164
|
+
const at = message.cursor ?? 0;
|
|
165
|
+
const plan = await ctx.step("sources", async (s) => {
|
|
166
|
+
s.progress("reading sources", { done: 0, total: message.instances.length, unit: "records" });
|
|
167
|
+
const records = await read.records();
|
|
168
|
+
const textual = fieldsOf(fields, (spec) => roleOf(spec) === "text");
|
|
169
|
+
const chunk: string[] = [];
|
|
170
|
+
let chars = 0;
|
|
171
|
+
for (const iri of message.instances.slice(at)) {
|
|
172
|
+
const record = records.get(iri);
|
|
173
|
+
const length = textual.reduce(
|
|
174
|
+
(sum, f) => sum + (record?.values[f] ?? []).reduce((n, v) => n + v.length, 0),
|
|
175
|
+
0,
|
|
176
|
+
);
|
|
177
|
+
if (
|
|
178
|
+
chunk.length &&
|
|
179
|
+
(chunk.length >= settings.sourcesPerTurn || chars + length > settings.textPerTurn)
|
|
180
|
+
)
|
|
181
|
+
break;
|
|
182
|
+
chunk.push(iri);
|
|
183
|
+
chars += length;
|
|
184
|
+
}
|
|
185
|
+
const gone = at === 0 ? message.removed : [];
|
|
186
|
+
const written = new Map<string, WrittenMention[]>();
|
|
187
|
+
for (const part of parts([...chunk, ...gone]))
|
|
188
|
+
for (const [source, list] of parseSourceMentions(
|
|
189
|
+
(await lbb.sparql(sourceMentionsQuery(part), strong)).rows,
|
|
190
|
+
))
|
|
191
|
+
written.set(source, list);
|
|
192
|
+
// A deleted record took the edges to it: its mentions are found as orphans.
|
|
193
|
+
if (gone.length) {
|
|
194
|
+
const orphans = (await lbb.sparql(orphanMentionsQuery(), strong)).rows
|
|
195
|
+
.filter((r) => r.m)
|
|
196
|
+
.map((r) => ({ mention: r.m!, thing: r.thing ?? null, cannot: [] }));
|
|
197
|
+
if (orphans.length) written.set(gone[0]!, [...(written.get(gone[0]!) ?? []), ...orphans]);
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
chunk,
|
|
201
|
+
gone,
|
|
202
|
+
written: Object.fromEntries(written),
|
|
203
|
+
names: Object.fromEntries(chunk.map((iri) => [iri, records.get(iri)?.name ?? ""])),
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const found: { source: string; mentions: FoundMention[] }[] = [];
|
|
208
|
+
for (const [k, iri] of plan.chunk.entries()) {
|
|
209
|
+
const mentions = await ctx.step(`mentions-${k}`, async (s) => {
|
|
210
|
+
s.progress("finding mentions", { done: k, total: plan.chunk.length, unit: "records" });
|
|
211
|
+
const record = (await read.records()).get(iri);
|
|
212
|
+
return record ? findMentions(s, lbb, settings, record, fields) : [];
|
|
213
|
+
});
|
|
214
|
+
found.push({ source: iri, mentions });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const cannotOf = new Map<string, string[]>();
|
|
218
|
+
const thingNow = new Map<string, string | null>();
|
|
219
|
+
for (const list of Object.values(plan.written))
|
|
220
|
+
for (const w of list) {
|
|
221
|
+
cannotOf.set(w.mention, w.cannot);
|
|
222
|
+
thingNow.set(w.mention, w.thing);
|
|
223
|
+
}
|
|
224
|
+
const mentions: SourceMention[] = found.flatMap(({ source, mentions: list }) =>
|
|
225
|
+
list.map((f) => {
|
|
226
|
+
const iri = mentionIri(message.graph, source, f.field, f.span, f.occurrence);
|
|
227
|
+
return {
|
|
228
|
+
...f,
|
|
229
|
+
iri,
|
|
230
|
+
source,
|
|
231
|
+
record: f.role === "self" ? source : null,
|
|
232
|
+
keys: keptKeys(blockKeys(f.span, f.kind)),
|
|
233
|
+
cannot: cannotOf.get(iri) ?? [],
|
|
234
|
+
thing: thingNow.get(iri) ?? null,
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const decided: LinkOutput = { decisions: [], identities: [], stop: {}, bounds: {} };
|
|
240
|
+
for (let n = 0; n * LINK_STEP < mentions.length; n++) {
|
|
241
|
+
const out = await ctx.step(`link-${n}`, (s) =>
|
|
242
|
+
linkPart(s, lbb, settings, cls, plan.names, mentions, n),
|
|
243
|
+
);
|
|
244
|
+
decided.decisions.push(...out.decisions);
|
|
245
|
+
decided.identities.push(...out.identities);
|
|
246
|
+
Object.assign(decided.stop, out.stop);
|
|
247
|
+
for (const [bound, count] of Object.entries(out.bounds))
|
|
248
|
+
decided.bounds[bound] = (decided.bounds[bound] ?? 0) + count;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const kindOf = new Map(
|
|
252
|
+
decided.decisions.filter((d) => d.kind).map((d) => [d.mention, d.kind!] as const),
|
|
253
|
+
);
|
|
254
|
+
const sources = [...plan.chunk, ...plan.gone];
|
|
255
|
+
for (const [k, source] of sources.entries())
|
|
256
|
+
await lbb.writeOwned(
|
|
257
|
+
ctx,
|
|
258
|
+
`write-${k}`,
|
|
259
|
+
(s) => {
|
|
260
|
+
s.progress("writing mentions", { done: k, total: sources.length, unit: "records" });
|
|
261
|
+
return mentionsWrite(
|
|
262
|
+
source,
|
|
263
|
+
cls,
|
|
264
|
+
plan.names[source] ?? "",
|
|
265
|
+
mentions.filter((m) => m.source === source),
|
|
266
|
+
plan.written[source] ?? [],
|
|
267
|
+
kindOf,
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
{ graph: message.graph },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const newIris = new Set(mentions.map((m) => m.iri));
|
|
274
|
+
const retracted = Object.values(plan.written)
|
|
275
|
+
.flat()
|
|
276
|
+
.filter((w) => !newIris.has(w.mention));
|
|
277
|
+
await ctx.step("send", async (s) => {
|
|
278
|
+
const messages = decidedMessages(settings, message, ctx, {
|
|
279
|
+
mentions: mentions.map((m) => idIn(m.iri)),
|
|
280
|
+
removed: retracted.map((w) => idIn(w.mention)),
|
|
281
|
+
things: [...new Set(retracted.map((w) => w.thing).filter((t): t is string => !!t))].map(idIn),
|
|
282
|
+
decisions: decided.decisions.map(compact),
|
|
283
|
+
identities: decided.identities.map((p) => ({ ...p, a: idIn(p.a), b: idIn(p.b) })),
|
|
284
|
+
});
|
|
285
|
+
for (const [n, body] of messages.entries()) {
|
|
286
|
+
s.progress("sending decisions", { done: n, total: messages.length, unit: "messages" });
|
|
287
|
+
await ports.send(thingsId(settings), body, {
|
|
288
|
+
id: decidedId(ctx, n),
|
|
289
|
+
origin: { kind: "app", reason: "decided", note: `${ctx.workflowId} turn ${ctx.turn}` },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return messages.length;
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const count = (outcome: Decision["outcome"]) =>
|
|
296
|
+
decided.decisions.filter((d) => d.outcome === outcome).length;
|
|
297
|
+
const stopKeysSeen = { ...state.stopKeys, ...decided.stop };
|
|
298
|
+
const next: ShardState = {
|
|
299
|
+
turns: state.turns + 1,
|
|
300
|
+
sources: state.sources + plan.chunk.length,
|
|
301
|
+
mentions: state.mentions + mentions.length,
|
|
302
|
+
links: state.links + count("link"),
|
|
303
|
+
review: state.review + count("review"),
|
|
304
|
+
nolink: state.nolink + count("nolink"),
|
|
305
|
+
alone: state.alone + count("none"),
|
|
306
|
+
identities: state.identities + decided.identities.length,
|
|
307
|
+
stopKeys: Object.fromEntries(Object.entries(stopKeysSeen).sort().slice(0, MAX_STOP_KEYS)),
|
|
308
|
+
bounds: { ...state.bounds },
|
|
309
|
+
};
|
|
310
|
+
for (const [bound, n] of Object.entries(decided.bounds))
|
|
311
|
+
next.bounds[bound] = (next.bounds[bound] ?? 0) + n;
|
|
312
|
+
const end = at + plan.chunk.length;
|
|
313
|
+
if (end < message.instances.length)
|
|
314
|
+
return { state: next, next: { ...message, removed: [], cursor: end } };
|
|
315
|
+
return { state: next };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The id of the `n`th part of a turn's decided message: `decided-<shard>-<turn>`, then `.1`, `.2`, … */
|
|
319
|
+
export const decidedId = (ctx: WorkflowContext, n: number) =>
|
|
320
|
+
`decided-${ctx.workflowId}-${ctx.turn}${n ? `.${n}` : ""}`;
|
|
321
|
+
|
|
322
|
+
/** A decision with entity ids for IRIs, for a message. */
|
|
323
|
+
const compact = (d: Decision): Decision => ({
|
|
324
|
+
...d,
|
|
325
|
+
mention: idIn(d.mention),
|
|
326
|
+
target: d.target ? idIn(d.target) : null,
|
|
327
|
+
thing: d.thing ? idIn(d.thing) : null,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
/** The decided message of a turn, in parts of at most 24,000 bytes. */
|
|
331
|
+
export function decidedMessages(
|
|
332
|
+
settings: Settings,
|
|
333
|
+
message: { graph: string; as_of_commit_seq: number },
|
|
334
|
+
ctx: { workflowId: string; turn: number },
|
|
335
|
+
body: Pick<DecidedMessage, "mentions" | "removed" | "things" | "decisions" | "identities">,
|
|
336
|
+
): DecidedMessage[] {
|
|
337
|
+
const empty = (part: number): DecidedMessage => ({
|
|
338
|
+
type: "decided",
|
|
339
|
+
workflow: settings.name,
|
|
340
|
+
graph: message.graph,
|
|
341
|
+
as_of_commit_seq: message.as_of_commit_seq,
|
|
342
|
+
from: ctx.workflowId,
|
|
343
|
+
turn: ctx.turn,
|
|
344
|
+
part,
|
|
345
|
+
mentions: [],
|
|
346
|
+
removed: [],
|
|
347
|
+
things: [],
|
|
348
|
+
decisions: [],
|
|
349
|
+
identities: [],
|
|
350
|
+
});
|
|
351
|
+
const out = [empty(0)];
|
|
352
|
+
const fits = (m: DecidedMessage) => JSON.stringify(m).length <= DECIDED_BYTES;
|
|
353
|
+
for (const field of ["mentions", "removed", "things", "decisions", "identities"] as const)
|
|
354
|
+
for (const item of body[field] as unknown[]) {
|
|
355
|
+
let last = out.at(-1)!;
|
|
356
|
+
(last[field] as unknown[]).push(item);
|
|
357
|
+
if (!fits(last) && (last[field] as unknown[]).length > 1) {
|
|
358
|
+
(last[field] as unknown[]).pop();
|
|
359
|
+
last = empty(out.length);
|
|
360
|
+
out.push(last);
|
|
361
|
+
(last[field] as unknown[]).push(item);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A turn of the reviews workflow: accepted review items wrote `SAME_THING_AS`; the things identity groups again. */
|
|
368
|
+
async function reviewsTurn(
|
|
369
|
+
ctx: WorkflowContext,
|
|
370
|
+
ports: LinkPorts,
|
|
371
|
+
settings: Settings,
|
|
372
|
+
message: SourceMessage,
|
|
373
|
+
) {
|
|
374
|
+
const origin = message.origin ?? ctx.origin ?? null;
|
|
375
|
+
const requests = origin?.requests ?? [];
|
|
376
|
+
// The link workflow's own writes wake the watch too: forward only an accept, or a change it cannot name.
|
|
377
|
+
if (requests.length && !requests.some((r) => r.startsWith("ontology-suggestion:"))) return;
|
|
378
|
+
if (!message.instances.length) return;
|
|
379
|
+
await ctx.step("send", async () => {
|
|
380
|
+
const [body] = decidedMessages(settings, message, ctx, {
|
|
381
|
+
mentions: message.instances.map(idIn),
|
|
382
|
+
removed: [],
|
|
383
|
+
things: [],
|
|
384
|
+
decisions: [],
|
|
385
|
+
identities: [],
|
|
386
|
+
});
|
|
387
|
+
await ports.send(thingsId(settings), body!, {
|
|
388
|
+
id: decidedId(ctx, 0),
|
|
389
|
+
origin: { kind: "app", reason: "accepted", note: `${ctx.workflowId} turn ${ctx.turn}` },
|
|
390
|
+
});
|
|
391
|
+
return 1;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The records of a message, read once per attempt at the message's commit. */
|
|
396
|
+
function reader(
|
|
397
|
+
lbb: LbbActivities,
|
|
398
|
+
message: SourceMessage,
|
|
399
|
+
cls: string,
|
|
400
|
+
fields: Record<string, FieldSpec>,
|
|
401
|
+
) {
|
|
402
|
+
let records: Promise<Map<string, SourceRecord>> | undefined;
|
|
403
|
+
const names = Object.keys(fields);
|
|
404
|
+
return {
|
|
405
|
+
records: () =>
|
|
406
|
+
(records ??= (async () => {
|
|
407
|
+
const out = new Map<string, SourceRecord>();
|
|
408
|
+
for (const part of parts(message.instances)) {
|
|
409
|
+
const { rows } = await lbb.sparql({
|
|
410
|
+
query: sourcesQuery(part, names),
|
|
411
|
+
as_of_commit_seq: message.as_of_commit_seq,
|
|
412
|
+
});
|
|
413
|
+
for (const [iri, record] of parseSources(rows, part, cls, names)) out.set(iri, record);
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
})()),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The keys among `keys` that one mention or more hold, and fewer than the stop limit. */
|
|
421
|
+
async function knownKeys(
|
|
422
|
+
lbb: LbbActivities,
|
|
423
|
+
keys: string[],
|
|
424
|
+
): Promise<{ known: Set<string>; counts: Map<string, number> }> {
|
|
425
|
+
const counts = new Map<string, number>();
|
|
426
|
+
for (const part of parts(keys, BOUNDS.keysPerRead))
|
|
427
|
+
for (const [key, n] of parseCounts((await lbb.sparql(keyCountsQuery(part), strong)).rows))
|
|
428
|
+
counts.set(key, n);
|
|
429
|
+
const known = new Set(
|
|
430
|
+
[...counts].filter(([, n]) => n >= 1 && n <= BOUNDS.stopKey).map(([k]) => k),
|
|
431
|
+
);
|
|
432
|
+
return { known, counts };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The mentions of one record. */
|
|
436
|
+
async function findMentions(
|
|
437
|
+
s: WorkflowStepContext,
|
|
438
|
+
lbb: LbbActivities,
|
|
439
|
+
settings: Settings,
|
|
440
|
+
record: SourceRecord,
|
|
441
|
+
fields: Record<string, FieldSpec>,
|
|
442
|
+
): Promise<FoundMention[]> {
|
|
443
|
+
const out = selfMentions(record, fields);
|
|
444
|
+
const texts = Object.entries(fields).filter(([, spec]) => roleOf(spec) === "text");
|
|
445
|
+
const values = texts.flatMap(([field]) => record.values[field] ?? []);
|
|
446
|
+
if (!values.length) return out;
|
|
447
|
+
const { known } = await knownKeys(lbb, gazetteerKeys(values));
|
|
448
|
+
let calls = 0;
|
|
449
|
+
for (const [field, spec] of texts)
|
|
450
|
+
for (const [i, text] of (record.values[field] ?? []).entries()) {
|
|
451
|
+
const found = textMentions(field, i, text, known, BOUNDS.textMentions);
|
|
452
|
+
if (settings.modelsReadRecords && extracts(spec) && found.length < BOUNDS.textMentions) {
|
|
453
|
+
const answer = await lbb.reason(extractRequest(record, field, text), {
|
|
454
|
+
effectId: calls ? `${s.effectId}.${calls}` : s.effectId,
|
|
455
|
+
label: `${record.class} ${field} mentions`.slice(0, 64),
|
|
456
|
+
});
|
|
457
|
+
calls += 1;
|
|
458
|
+
found.push(...checkExtracted(answer.value, field, i, text, found, BOUNDS.textMentions));
|
|
459
|
+
}
|
|
460
|
+
out.push(...found);
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** What one `link-<n>` step saves. */
|
|
466
|
+
interface LinkOutput {
|
|
467
|
+
decisions: Decision[];
|
|
468
|
+
identities: IdentityPair[];
|
|
469
|
+
stop: Record<string, number>;
|
|
470
|
+
bounds: Record<string, number>;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The candidates and decisions of the `n`th 40 mentions of the turn. */
|
|
474
|
+
async function linkPart(
|
|
475
|
+
s: WorkflowStepContext,
|
|
476
|
+
lbb: LbbActivities,
|
|
477
|
+
settings: Settings,
|
|
478
|
+
cls: string,
|
|
479
|
+
names: Record<string, string>,
|
|
480
|
+
all: SourceMention[],
|
|
481
|
+
n: number,
|
|
482
|
+
): Promise<LinkOutput> {
|
|
483
|
+
const part = all.slice(n * LINK_STEP, (n + 1) * LINK_STEP);
|
|
484
|
+
const out: LinkOutput = { decisions: [], identities: [], stop: {}, bounds: {} };
|
|
485
|
+
const bound = (name: string) => (out.bounds[name] = (out.bounds[name] ?? 0) + 1);
|
|
486
|
+
s.progress("finding candidates", { done: n * LINK_STEP, total: all.length, unit: "mentions" });
|
|
487
|
+
|
|
488
|
+
// Keys: the counts first, so a stop key finds nothing; then the mentions of the others.
|
|
489
|
+
const keys = [...new Set(part.flatMap((m) => m.keys))];
|
|
490
|
+
const { counts } = await knownKeys(lbb, keys);
|
|
491
|
+
const stop = new Set(stopKeys(counts, BOUNDS.stopKey));
|
|
492
|
+
for (const key of stop) out.stop[key] = counts.get(key)!;
|
|
493
|
+
const wanted = keys.filter((k) => !stop.has(k) && (counts.get(k) ?? 0) > 0);
|
|
494
|
+
const hits: KeyHit[] = [];
|
|
495
|
+
for (const chunk of parts(wanted, BOUNDS.keysPerRead)) {
|
|
496
|
+
const rows = (await lbb.sparql(keyHitsQuery(chunk), strong)).rows;
|
|
497
|
+
if (rows.length >= BOUNDS.keyRows) bound("key_rows");
|
|
498
|
+
hits.push(...parseHits(rows));
|
|
499
|
+
}
|
|
500
|
+
// The new mentions of this turn are not written yet: they are candidates of each other too.
|
|
501
|
+
for (const m of all.filter((x) => x.thing === null && !hits.some((h) => h.mention === x.iri)))
|
|
502
|
+
for (const key of m.keys)
|
|
503
|
+
if (!stop.has(key))
|
|
504
|
+
hits.push({
|
|
505
|
+
key,
|
|
506
|
+
mention: m.iri,
|
|
507
|
+
role: m.role,
|
|
508
|
+
span: m.span,
|
|
509
|
+
source: m.source,
|
|
510
|
+
kind: m.kind,
|
|
511
|
+
window: m.window ?? null,
|
|
512
|
+
thing: null,
|
|
513
|
+
label: null,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// The neighbours of the sources of `refers` mentions, two hops out.
|
|
517
|
+
const sources = [...new Set(part.map((m) => m.source))];
|
|
518
|
+
const near = await neighbours(lbb, sources, bound);
|
|
519
|
+
|
|
520
|
+
// For the identities of `self` mentions: the names near each record and its context values.
|
|
521
|
+
const contexts = await recordContexts(lbb, settings, part, hits, near.hops);
|
|
522
|
+
|
|
523
|
+
const candidates = new Map<string, Candidate[]>();
|
|
524
|
+
for (const m of part) {
|
|
525
|
+
const list = candidatesFor(
|
|
526
|
+
m,
|
|
527
|
+
hits,
|
|
528
|
+
stop,
|
|
529
|
+
m.role === "refers" ? (near.bySource.get(m.source) ?? []) : [],
|
|
530
|
+
);
|
|
531
|
+
if (list.length >= BOUNDS.total) bound("candidates");
|
|
532
|
+
candidates.set(m.iri, list);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
s.progress("linking mentions", { done: n * LINK_STEP, total: all.length, unit: "mentions" });
|
|
536
|
+
if (!settings.modelsReadRecords) {
|
|
537
|
+
for (const m of part) {
|
|
538
|
+
const decided = rulesDecisions(m, candidates.get(m.iri)!, contexts);
|
|
539
|
+
out.decisions.push(...decided.decisions);
|
|
540
|
+
out.identities.push(...decided.identities);
|
|
541
|
+
}
|
|
542
|
+
claimedOnce(out.decisions, part);
|
|
543
|
+
return out;
|
|
544
|
+
}
|
|
545
|
+
await decideByModel(
|
|
546
|
+
s,
|
|
547
|
+
lbb,
|
|
548
|
+
settings,
|
|
549
|
+
cls,
|
|
550
|
+
names,
|
|
551
|
+
part,
|
|
552
|
+
candidates,
|
|
553
|
+
contexts,
|
|
554
|
+
near.relations,
|
|
555
|
+
out,
|
|
556
|
+
);
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The records two hops from the sources, and the mentions on them. */
|
|
561
|
+
async function neighbours(
|
|
562
|
+
lbb: LbbActivities,
|
|
563
|
+
sources: string[],
|
|
564
|
+
bound: (name: string) => void,
|
|
565
|
+
): Promise<{
|
|
566
|
+
bySource: Map<string, { mention: NeighbourMention; via: string }[]>;
|
|
567
|
+
hops: Hop[];
|
|
568
|
+
relations: Map<string, string[]>;
|
|
569
|
+
}> {
|
|
570
|
+
const first = parseHops((await lbb.sparql(hopQuery(sources), strong)).rows);
|
|
571
|
+
const reachedFirst = [...new Set(first.map((h) => h.to))].slice(0, BOUNDS.neighbourRecords);
|
|
572
|
+
const second = reachedFirst.length
|
|
573
|
+
? parseHops((await lbb.sparql(hopQuery(reachedFirst), strong)).rows)
|
|
574
|
+
: [];
|
|
575
|
+
const paths = neighbourPaths(sources, [first, second]);
|
|
576
|
+
const records = [...new Set([...paths.values()].flatMap((p) => [...p.keys()]))];
|
|
577
|
+
if (records.length >= BOUNDS.neighbourRecords) bound("neighbour_records");
|
|
578
|
+
const mentions = records.length
|
|
579
|
+
? parseNeighbourMentions(
|
|
580
|
+
(
|
|
581
|
+
await lbb.sparql(
|
|
582
|
+
neighbourMentionsQuery(records.slice(0, BOUNDS.neighbourRecords)),
|
|
583
|
+
strong,
|
|
584
|
+
)
|
|
585
|
+
).rows,
|
|
586
|
+
)
|
|
587
|
+
: [];
|
|
588
|
+
const bySource = new Map<string, { mention: NeighbourMention; via: string }[]>();
|
|
589
|
+
for (const [source, reached] of paths)
|
|
590
|
+
bySource.set(
|
|
591
|
+
source,
|
|
592
|
+
mentions
|
|
593
|
+
.filter((m) => reached.has(m.record))
|
|
594
|
+
.map((m) => ({ mention: m, via: reached.get(m.record)! })),
|
|
595
|
+
);
|
|
596
|
+
const relations = new Map<string, string[]>();
|
|
597
|
+
for (const h of first)
|
|
598
|
+
(relations.get(h.from) ?? relations.set(h.from, []).get(h.from)!).push(
|
|
599
|
+
`${h.dir === "in" ? `${h.class || "record"} "${h.name}" ${h.relation} this` : `${h.relation} ${h.class || "record"} "${h.name}"`}`,
|
|
600
|
+
);
|
|
601
|
+
return { bySource, hops: [...first, ...second], relations };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* For each record whose self mention has a candidate with another record:
|
|
606
|
+
* the normal names of the records it is related to and of its context field
|
|
607
|
+
* values. Two records that share one are corroborated (the rules' identity).
|
|
608
|
+
*/
|
|
609
|
+
async function recordContexts(
|
|
610
|
+
lbb: LbbActivities,
|
|
611
|
+
settings: Settings,
|
|
612
|
+
part: SourceMention[],
|
|
613
|
+
hits: KeyHit[],
|
|
614
|
+
known: Hop[],
|
|
615
|
+
): Promise<Map<string, RecordContext>> {
|
|
616
|
+
const records = new Set<string>();
|
|
617
|
+
for (const m of part) {
|
|
618
|
+
if (m.role !== "self") continue;
|
|
619
|
+
records.add(m.source);
|
|
620
|
+
for (const h of hits)
|
|
621
|
+
if (h.role === "self" && h.source !== m.source && m.keys.includes(h.key))
|
|
622
|
+
records.add(h.source);
|
|
623
|
+
}
|
|
624
|
+
const out = new Map<string, RecordContext>();
|
|
625
|
+
if (!records.size) return out;
|
|
626
|
+
const list = [...records];
|
|
627
|
+
const hops = [...known.filter((h) => records.has(h.from))];
|
|
628
|
+
const missing = list.filter((r) => !known.some((h) => h.from === r));
|
|
629
|
+
for (const chunk of parts(missing, 50))
|
|
630
|
+
hops.push(...parseHops((await lbb.sparql(hopQuery(chunk), strong)).rows));
|
|
631
|
+
const contextFields = [
|
|
632
|
+
...new Set(
|
|
633
|
+
Object.values(settings.fields).flatMap((f) =>
|
|
634
|
+
fieldsOf(f, (spec) => roleOf(spec) === "context"),
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
];
|
|
638
|
+
const values = new Map<string, string[]>();
|
|
639
|
+
if (contextFields.length)
|
|
640
|
+
for (const chunk of parts(list)) {
|
|
641
|
+
const { rows } = await lbb.sparql(sourcesQuery(chunk, contextFields), strong);
|
|
642
|
+
for (const [iri, record] of parseSources(rows, chunk, "", contextFields))
|
|
643
|
+
values.set(iri, Object.values(record.values).flat());
|
|
644
|
+
}
|
|
645
|
+
for (const record of list) {
|
|
646
|
+
const near = new Set<string>();
|
|
647
|
+
for (const h of hops) if (h.from === record && h.name) near.add(normalName(h.name));
|
|
648
|
+
for (const v of values.get(record) ?? []) near.add(normalName(v));
|
|
649
|
+
near.delete("");
|
|
650
|
+
out.set(record, { near });
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** The decisions of the decision model, per source: one state, packed requests, content effect ids. */
|
|
656
|
+
async function decideByModel(
|
|
657
|
+
s: WorkflowStepContext,
|
|
658
|
+
lbb: LbbActivities,
|
|
659
|
+
settings: Settings,
|
|
660
|
+
cls: string,
|
|
661
|
+
names: Record<string, string>,
|
|
662
|
+
part: SourceMention[],
|
|
663
|
+
candidates: Map<string, Candidate[]>,
|
|
664
|
+
contexts: Map<string, RecordContext>,
|
|
665
|
+
relations: Map<string, string[]>,
|
|
666
|
+
out: LinkOutput,
|
|
667
|
+
) {
|
|
668
|
+
const sources = [...new Set(part.map((m) => m.source))];
|
|
669
|
+
for (const source of sources) {
|
|
670
|
+
const mentions = part.filter((m) => m.source === source);
|
|
671
|
+
const asked: Asked[] = [];
|
|
672
|
+
const pairs: (AskedPair & {
|
|
673
|
+
record: string;
|
|
674
|
+
other: string;
|
|
675
|
+
mention: string;
|
|
676
|
+
candidate: Candidate;
|
|
677
|
+
kind: "identity" | "self";
|
|
678
|
+
})[] = [];
|
|
679
|
+
mentions.forEach((m, position) => {
|
|
680
|
+
const list = candidates.get(m.iri)!;
|
|
681
|
+
if (m.role === "refers") {
|
|
682
|
+
const strongOnes = list.filter((c) => c.strong);
|
|
683
|
+
const distinct = new Set(strongOnes.map((c) => c.thing ?? c.target));
|
|
684
|
+
if (distinct.size === 1) {
|
|
685
|
+
out.decisions.push(rulesDecisions(m, list).decisions[0]!);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (!list.length) {
|
|
689
|
+
out.decisions.push(none(m.iri, list));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
asked.push({
|
|
693
|
+
position,
|
|
694
|
+
span: m.span,
|
|
695
|
+
context: `${cls} "${names[source] ?? ""}", field ${m.field}: "${m.window ?? m.span}".`,
|
|
696
|
+
kind: m.kind === "text",
|
|
697
|
+
candidates: strongOnes.length > 1 ? strongOnes : list,
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// A self mention: identities with other records, links to things without a record.
|
|
702
|
+
let any = false;
|
|
703
|
+
list.forEach((c, k) => {
|
|
704
|
+
const others = c.records.filter((r) => r !== m.record);
|
|
705
|
+
if (others.length) {
|
|
706
|
+
for (const other of others) {
|
|
707
|
+
if (c.strong) {
|
|
708
|
+
out.identities.push({
|
|
709
|
+
a: m.record!,
|
|
710
|
+
b: other,
|
|
711
|
+
confidence: 1,
|
|
712
|
+
reason: `the same ${c.found}`,
|
|
713
|
+
});
|
|
714
|
+
any = true;
|
|
715
|
+
} else if (c.found === "name" || c.found === "alias" || c.found === "name:initial") {
|
|
716
|
+
// The same two records give the same question from either side.
|
|
717
|
+
const mine = `${cls} "${m.span}"${describeNear(contexts.get(m.record!))}`;
|
|
718
|
+
const theirs = `"${c.label}"${describeNear(contexts.get(other))}`;
|
|
719
|
+
const [a, b] = m.record! < other ? [mine, theirs] : [theirs, mine];
|
|
720
|
+
pairs.push({
|
|
721
|
+
id: `p${position}x${k}x${pairs.length}`,
|
|
722
|
+
a,
|
|
723
|
+
b,
|
|
724
|
+
match: c.found,
|
|
725
|
+
record: m.record!,
|
|
726
|
+
other,
|
|
727
|
+
mention: m.iri,
|
|
728
|
+
candidate: c,
|
|
729
|
+
kind: "identity",
|
|
730
|
+
});
|
|
731
|
+
any = true;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (c.records.length) return;
|
|
737
|
+
any = true;
|
|
738
|
+
if (c.strong) out.decisions.push(rulesDecisions(m, [c]).decisions[0]!);
|
|
739
|
+
else
|
|
740
|
+
pairs.push({
|
|
741
|
+
id: `p${position}x${k}x${pairs.length}`,
|
|
742
|
+
a: `the span "${c.spans[0] ?? c.label}"${c.window ? ` in "${c.window}"` : ""}`,
|
|
743
|
+
b: `the ${cls} "${m.span}"${describeNear(contexts.get(m.record!))}`,
|
|
744
|
+
match: c.found,
|
|
745
|
+
record: m.record!,
|
|
746
|
+
other: "",
|
|
747
|
+
mention: m.iri,
|
|
748
|
+
candidate: c,
|
|
749
|
+
kind: "self",
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
if (!any) out.decisions.push(none(m.iri, list));
|
|
753
|
+
});
|
|
754
|
+
if (!asked.length && !pairs.length) continue;
|
|
755
|
+
const state = sourceState(`${cls} "${names[source] ?? ""}"`, relations.get(source) ?? []);
|
|
756
|
+
const answers: Record<string, DecideAnswer> = {};
|
|
757
|
+
const ask = async (
|
|
758
|
+
request: DecideRequest,
|
|
759
|
+
items: unknown[],
|
|
760
|
+
again: (half: unknown[]) => DecideRequest,
|
|
761
|
+
) => {
|
|
762
|
+
try {
|
|
763
|
+
const answered = await lbb.decide(request, {
|
|
764
|
+
effectId: effectIdOf(request),
|
|
765
|
+
label: `${cls} links`.slice(0, 64),
|
|
766
|
+
});
|
|
767
|
+
Object.assign(answers, answered.answers);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
if (!isActivityRefused(error)) throw error;
|
|
770
|
+
const halves = splitInHalves(items);
|
|
771
|
+
if (halves) for (const half of halves) await ask(again(half), half, again);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
for (const group of packRequests(state, asked))
|
|
775
|
+
await ask(decideRequest(state, group), group, (half) =>
|
|
776
|
+
decideRequest(state, half as Asked[]),
|
|
777
|
+
);
|
|
778
|
+
for (const group of parts(pairs, 32))
|
|
779
|
+
await ask(decideRequest(state, [], group), group, (half) =>
|
|
780
|
+
decideRequest(state, [], half as AskedPair[]),
|
|
781
|
+
);
|
|
782
|
+
for (const a of asked) {
|
|
783
|
+
const m = mentions[a.position]!;
|
|
784
|
+
out.decisions.push(
|
|
785
|
+
linkRule(
|
|
786
|
+
m.iri,
|
|
787
|
+
a,
|
|
788
|
+
answers[`m${a.position}-kind`],
|
|
789
|
+
answers[`m${a.position}-names`],
|
|
790
|
+
settings.gate,
|
|
791
|
+
),
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
for (const pair of pairs) {
|
|
795
|
+
const answer = answers[`${pair.id}-same`];
|
|
796
|
+
if (pair.kind === "identity") {
|
|
797
|
+
const identity = sameRecord(pair.record, pair.other, answer, pair.match);
|
|
798
|
+
if (identity) out.identities.push(identity);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const p =
|
|
802
|
+
answer?.probabilities?.same ?? (answer?.choice === "same" ? (answer.confidence ?? 0) : 0);
|
|
803
|
+
const confidence = Math.round(p * 1000) / 1000;
|
|
804
|
+
if (confidence < 0.5) continue;
|
|
805
|
+
out.decisions.push({
|
|
806
|
+
mention: pair.mention,
|
|
807
|
+
outcome: confidence >= settings.gate ? "link" : "review",
|
|
808
|
+
target: pair.candidate.target,
|
|
809
|
+
thing: pair.candidate.thing,
|
|
810
|
+
confidence,
|
|
811
|
+
by: "model",
|
|
812
|
+
found: pair.candidate.found,
|
|
813
|
+
kind: null,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
for (const m of mentions)
|
|
817
|
+
if (m.role === "self" && !out.decisions.some((d) => d.mention === m.iri))
|
|
818
|
+
out.decisions.push(none(m.iri, candidates.get(m.iri)!));
|
|
819
|
+
}
|
|
820
|
+
claimedOnce(out.decisions, part);
|
|
821
|
+
s.progress("linking mentions", { done: part.length, total: part.length, unit: "mentions" });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* A mention without a thing that the self mentions of two records link to
|
|
826
|
+
* is no link of either: a person decides. Without it, the record whose pair
|
|
827
|
+
* the grouping takes first would win.
|
|
828
|
+
*/
|
|
829
|
+
export function claimedOnce(
|
|
830
|
+
decisions: Decision[],
|
|
831
|
+
part: { iri: string; role: string; record: string | null }[],
|
|
832
|
+
) {
|
|
833
|
+
const recordOf = new Map(part.map((m) => [m.iri, m.record]));
|
|
834
|
+
const claims = new Map<string, Set<string>>();
|
|
835
|
+
for (const d of decisions)
|
|
836
|
+
if (d.outcome === "link" && d.target && !d.thing && recordOf.get(d.mention))
|
|
837
|
+
(claims.get(d.target) ?? claims.set(d.target, new Set()).get(d.target)!).add(
|
|
838
|
+
recordOf.get(d.mention)!,
|
|
839
|
+
);
|
|
840
|
+
for (const d of decisions)
|
|
841
|
+
if (d.outcome === "link" && d.target && (claims.get(d.target)?.size ?? 0) > 1) {
|
|
842
|
+
d.outcome = "review";
|
|
843
|
+
d.confidence = Math.min(d.confidence, 0.5);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const describeNear = (context: RecordContext | undefined) =>
|
|
848
|
+
context?.near.size ? ` (related: ${[...context.near].slice(0, 6).join(", ")})` : "";
|
|
849
|
+
|
|
850
|
+
/** The owned write of one record: its mentions, their fields and keys, and `MENTIONED_IN`; the mentions it no longer has go. */
|
|
851
|
+
export function mentionsWrite(
|
|
852
|
+
source: string,
|
|
853
|
+
cls: string,
|
|
854
|
+
name: string,
|
|
855
|
+
mentions: SourceMention[],
|
|
856
|
+
written: WrittenMention[],
|
|
857
|
+
kindOf: Map<string, string> = new Map(),
|
|
858
|
+
): OwnedWrite {
|
|
859
|
+
const keep = new Set(mentions.map((m) => m.iri));
|
|
860
|
+
const ref = (m: { iri: string; span: string }) => ({
|
|
861
|
+
type: "Mention",
|
|
862
|
+
name: m.span.slice(0, 200),
|
|
863
|
+
key: `${ENTITY_ID_KEY_SENTINEL}${idIn(m.iri)}`,
|
|
864
|
+
});
|
|
865
|
+
const target = entityRef(source, cls, name || undefined);
|
|
866
|
+
return {
|
|
867
|
+
subjects: mentions.map(ref),
|
|
868
|
+
relations: ["MENTIONED_IN"],
|
|
869
|
+
properties: MENTION_FIELDS,
|
|
870
|
+
retract_entities: written
|
|
871
|
+
.filter((w) => !keep.has(w.mention))
|
|
872
|
+
.map((w) => ({
|
|
873
|
+
type: "Mention",
|
|
874
|
+
name: idIn(w.mention),
|
|
875
|
+
key: `${ENTITY_ID_KEY_SENTINEL}${idIn(w.mention)}`,
|
|
876
|
+
})),
|
|
877
|
+
entity_properties: mentions.map((m) => ({
|
|
878
|
+
...ref(m),
|
|
879
|
+
properties: {
|
|
880
|
+
span: m.span,
|
|
881
|
+
role: m.role,
|
|
882
|
+
kind: kindOf.get(m.iri) ?? spanKindName(m.kind),
|
|
883
|
+
field: m.field,
|
|
884
|
+
offset: m.offset,
|
|
885
|
+
length: m.length,
|
|
886
|
+
block_key: m.keys,
|
|
887
|
+
...(m.window ? { window: m.window } : {}),
|
|
888
|
+
},
|
|
889
|
+
})),
|
|
890
|
+
triplets: mentions.map((m) => ({ source: ref(m), relation: "MENTIONED_IN", target })),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const spanKindName = (kind: SpanKind) => (kind === "text" ? "name" : kind);
|