@littlebigbrain/create 0.2.0 → 0.4.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 +7 -2
- package/lib/create.mjs +28 -4
- package/package.json +1 -1
- package/templates/_shared/testing.ts +185 -5
- 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/rows/README.md +150 -0
- package/templates/rows/csv.ts +260 -0
- package/templates/rows/samples/expected.json +150 -0
- package/templates/rows/samples/properties.json +11 -0
- package/templates/rows/samples/q3-customers.csv +10 -0
- package/templates/rows/samples/q3-orders.tsv +5 -0
- package/templates/rows/samples/reports.json +5 -0
- package/templates/rows/samples.ts +56 -0
- package/templates/rows/setup.ts +18 -0
- package/templates/rows/try.ts +32 -0
- package/templates/rows/worker.ts +24 -0
- package/templates/rows/workflow.test.ts +541 -0
- package/templates/rows/workflow.ts +657 -0
- package/templates/sections/README.md +118 -0
- package/templates/sections/markdown.ts +121 -0
- package/templates/sections/samples/expected.json +82 -0
- package/templates/sections/samples/guides.json +4 -0
- package/templates/sections/samples/support-handbook.md +34 -0
- package/templates/sections/samples.ts +41 -0
- package/templates/sections/setup.ts +18 -0
- package/templates/sections/try.ts +32 -0
- package/templates/sections/worker.ts +24 -0
- package/templates/sections/workflow.test.ts +265 -0
- package/templates/sections/workflow.ts +433 -0
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// The field roles of records (general linking 3.2) and how mentions are found
|
|
2
|
+
// in their fields (3.3). Pure functions: the workflow reads the records and
|
|
3
|
+
// asks the graph which keys exist.
|
|
4
|
+
//
|
|
5
|
+
// A `name` or `key:*` field gives one `self` mention per value: the value is
|
|
6
|
+
// the span. A `text` field gives `refers` mentions: first the gazetteer pass,
|
|
7
|
+
// which keeps each span whose name or key a mention of the graph already
|
|
8
|
+
// has; then the spans written like a name or a key (`M. Tannenholt`,
|
|
9
|
+
// `Bob Haldane`, an email, a code), which a later record can find by its
|
|
10
|
+
// keys. Only a field that opts in (`{ role: "text", extract: true }`), on a
|
|
11
|
+
// workflow whose models may read record values, goes to the reasoning model.
|
|
12
|
+
import type { ReasonRequest } from "@littlebigbrain/client";
|
|
13
|
+
import { blockKeys, fold, keyWeight, looksLike, type SpanKind } from "../names.js";
|
|
14
|
+
|
|
15
|
+
/** The role of a field. `context` is read for the decisions and gives no mention. */
|
|
16
|
+
export type FieldRole =
|
|
17
|
+
| "name"
|
|
18
|
+
| "name:given"
|
|
19
|
+
| "name:family"
|
|
20
|
+
| "key:email"
|
|
21
|
+
| "key:phone"
|
|
22
|
+
| "key:web"
|
|
23
|
+
| "key:handle"
|
|
24
|
+
| "key:code"
|
|
25
|
+
| "text"
|
|
26
|
+
| "context";
|
|
27
|
+
|
|
28
|
+
/** A field's role, and for a `text` field whether the reasoning model may read it. */
|
|
29
|
+
export type FieldSpec = FieldRole | { role: FieldRole; extract?: boolean };
|
|
30
|
+
|
|
31
|
+
export const roleOf = (spec: FieldSpec): FieldRole => (typeof spec === "string" ? spec : spec.role);
|
|
32
|
+
export const extracts = (spec: FieldSpec): boolean =>
|
|
33
|
+
typeof spec !== "string" && spec.role === "text" && spec.extract === true;
|
|
34
|
+
|
|
35
|
+
/** A record as the workflow reads it: its class, display name and the values of its fields. */
|
|
36
|
+
export interface SourceRecord {
|
|
37
|
+
iri: string;
|
|
38
|
+
/** The watched class the record came in as. */
|
|
39
|
+
class: string;
|
|
40
|
+
name: string;
|
|
41
|
+
/** The values of each field the settings name. */
|
|
42
|
+
values: Record<string, string[]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MentionRole = "self" | "refers";
|
|
46
|
+
|
|
47
|
+
/** A mention found in a field of a record, before it has an IRI. */
|
|
48
|
+
export interface FoundMention {
|
|
49
|
+
/** The field: a property name, or `given+family` for a name joined from two fields. */
|
|
50
|
+
field: string;
|
|
51
|
+
role: MentionRole;
|
|
52
|
+
/** What the span is: a name, a key of one kind, or a span of text. */
|
|
53
|
+
kind: SpanKind;
|
|
54
|
+
span: string;
|
|
55
|
+
/** The value of the field the span is in (a field can hold several). */
|
|
56
|
+
value: number;
|
|
57
|
+
/** Where the span starts in that value, in UTF-16 code units, and its length. */
|
|
58
|
+
offset: number;
|
|
59
|
+
length: number;
|
|
60
|
+
/** The span's place among the equal spans of the field: 0 for the first. */
|
|
61
|
+
occurrence: number;
|
|
62
|
+
/** For a span of text, the sentence that holds it, at most 300 characters. */
|
|
63
|
+
window?: string;
|
|
64
|
+
found: "field" | "gazetteer" | "shape" | "model";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const KEY_ROLES: Record<string, SpanKind> = {
|
|
68
|
+
"key:email": "email",
|
|
69
|
+
"key:phone": "phone",
|
|
70
|
+
"key:web": "web",
|
|
71
|
+
"key:handle": "handle",
|
|
72
|
+
"key:code": "code",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The `self` mentions of a record: each name and key value, and a name joined from given and family fields. */
|
|
76
|
+
export function selfMentions(
|
|
77
|
+
record: SourceRecord,
|
|
78
|
+
fields: Record<string, FieldSpec>,
|
|
79
|
+
): FoundMention[] {
|
|
80
|
+
const out: FoundMention[] = [];
|
|
81
|
+
const counts = new Map<string, number>();
|
|
82
|
+
const push = (field: string, kind: SpanKind, span: string, value: number) => {
|
|
83
|
+
const text = span.trim();
|
|
84
|
+
if (!text || text.length > 200) return;
|
|
85
|
+
if (kind !== "name" && !blockKeys(text, kind).length) return;
|
|
86
|
+
const seen = counts.get(`${field}\u0000${text}`) ?? 0;
|
|
87
|
+
counts.set(`${field}\u0000${text}`, seen + 1);
|
|
88
|
+
out.push({
|
|
89
|
+
field,
|
|
90
|
+
role: "self",
|
|
91
|
+
kind,
|
|
92
|
+
span: text,
|
|
93
|
+
value,
|
|
94
|
+
offset: 0,
|
|
95
|
+
length: text.length,
|
|
96
|
+
occurrence: seen,
|
|
97
|
+
found: "field",
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
let given: string | undefined;
|
|
101
|
+
let family: string | undefined;
|
|
102
|
+
for (const [field, spec] of Object.entries(fields)) {
|
|
103
|
+
const role = roleOf(spec);
|
|
104
|
+
const values = record.values[field] ?? [];
|
|
105
|
+
if (role === "name") values.forEach((v, i) => push(field, "name", v, i));
|
|
106
|
+
else if (role === "name:given") given ??= field;
|
|
107
|
+
else if (role === "name:family") family ??= field;
|
|
108
|
+
else if (KEY_ROLES[role]) values.forEach((v, i) => push(field, KEY_ROLES[role]!, v, i));
|
|
109
|
+
}
|
|
110
|
+
const first = (field?: string) => (field ? (record.values[field]?.[0] ?? "").trim() : "");
|
|
111
|
+
const joined = [first(given), first(family)].filter(Boolean).join(" ");
|
|
112
|
+
if (joined) push(`${given ?? family}+${family ?? given}`, "name", joined, 0);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- spans of text ---------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** One token of a text: a word, or an email, a web address or a phone number written as one. */
|
|
119
|
+
interface Token {
|
|
120
|
+
text: string;
|
|
121
|
+
start: number;
|
|
122
|
+
end: number;
|
|
123
|
+
/** A comma, a parenthesis or another break stands between it and the token before. */
|
|
124
|
+
comma: boolean;
|
|
125
|
+
/** A break stronger than a comma (a sentence end, a colon, a bracket) stands before it. */
|
|
126
|
+
stop: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const TOKEN =
|
|
130
|
+
/[^\s@<>()[\],;:"]+@[^\s@<>()[\],;:"]+\.[A-Za-z0-9-]{2,}|(?:https?:\/\/|www\.)[^\s<>()"]+|\+\d[\d ()./-]{6,}\d|[\p{L}\p{N}][\p{L}\p{N}'’\-‐‑./]*/gu;
|
|
131
|
+
|
|
132
|
+
/** The tokens of a text, with what stands between them. An initial keeps its dot (`M.`). */
|
|
133
|
+
export function tokens(text: string): Token[] {
|
|
134
|
+
const out: Token[] = [];
|
|
135
|
+
let last = 0;
|
|
136
|
+
for (const match of text.matchAll(TOKEN)) {
|
|
137
|
+
const start = match.index;
|
|
138
|
+
// A word ends before trailing dots and joiners, except the dot of an initial.
|
|
139
|
+
const trimmed = match[0].replace(/[.\-‐‑'’/]+$/u, "") || match[0];
|
|
140
|
+
const initial = /^\p{L}$/u.test(trimmed) && match[0].startsWith(`${trimmed}.`);
|
|
141
|
+
const word = initial ? `${trimmed}.` : trimmed;
|
|
142
|
+
const between = text.slice(last, start);
|
|
143
|
+
out.push({
|
|
144
|
+
text: word,
|
|
145
|
+
start,
|
|
146
|
+
end: start + word.length,
|
|
147
|
+
comma: between.includes(","),
|
|
148
|
+
stop: /[.!?;:()[\]"|\n]|\s[-–—]\s/.test(between),
|
|
149
|
+
});
|
|
150
|
+
last = start + word.length;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const capitalized = (word: string) => /^\p{Lu}/u.test(word);
|
|
156
|
+
const isInitial = (word: string) => /^\p{L}\.?$/u.test(word);
|
|
157
|
+
const isLetterWord = (word: string) => /^[\p{L}'’\-‐‑.]+$/u.test(word);
|
|
158
|
+
|
|
159
|
+
/** A span of text with its place, and the tokens it spans. */
|
|
160
|
+
export interface TextSpan {
|
|
161
|
+
span: string;
|
|
162
|
+
offset: number;
|
|
163
|
+
length: number;
|
|
164
|
+
words: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Lower-case words a name can hold between its capitalized words (`Lena van Dijk`). */
|
|
168
|
+
const PARTICLES = new Set([
|
|
169
|
+
"van",
|
|
170
|
+
"von",
|
|
171
|
+
"de",
|
|
172
|
+
"der",
|
|
173
|
+
"den",
|
|
174
|
+
"da",
|
|
175
|
+
"di",
|
|
176
|
+
"du",
|
|
177
|
+
"la",
|
|
178
|
+
"le",
|
|
179
|
+
"del",
|
|
180
|
+
"della",
|
|
181
|
+
"ten",
|
|
182
|
+
"ter",
|
|
183
|
+
"zu",
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The spans the gazetteer looks up in a text: one to four tokens within a
|
|
188
|
+
* sentence, the first written with a capital or as a key, the others with
|
|
189
|
+
* a capital, as an initial or as a name particle. Two capitalized words may
|
|
190
|
+
* stand on either side of one comma (`Wren, Tobias`).
|
|
191
|
+
*/
|
|
192
|
+
export function textSpans(text: string): TextSpan[] {
|
|
193
|
+
const words = tokens(text);
|
|
194
|
+
const out: TextSpan[] = [];
|
|
195
|
+
for (let i = 0; i < words.length; i++) {
|
|
196
|
+
const first = words[i]!;
|
|
197
|
+
const key = keyShaped(first.text);
|
|
198
|
+
if (!key && !capitalized(first.text)) continue;
|
|
199
|
+
let comma = false;
|
|
200
|
+
for (let n = 1; n <= 4 && i + n <= words.length; n++) {
|
|
201
|
+
const last = words[i + n - 1]!;
|
|
202
|
+
if (n > 1) {
|
|
203
|
+
if (key || comma || last.stop) break;
|
|
204
|
+
if (!capitalized(last.text) && !isInitial(last.text) && !PARTICLES.has(last.text)) break;
|
|
205
|
+
if (last.comma) {
|
|
206
|
+
if (n !== 2 || !capitalized(first.text) || !capitalized(last.text)) break;
|
|
207
|
+
comma = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
out.push({
|
|
211
|
+
span: text.slice(first.start, last.end),
|
|
212
|
+
offset: first.start,
|
|
213
|
+
length: last.end - first.start,
|
|
214
|
+
words: n,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A token written as an email, a web address, a phone number or a code. */
|
|
222
|
+
const keyShaped = (token: string): SpanKind | null =>
|
|
223
|
+
(["email", "web", "phone", "code"] as const).find((k) => looksLike(token, k)) ?? null;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Words that start a sentence of a note with a capital, and start no name:
|
|
227
|
+
* a span written like a name does not start with one (`Met Kim Oak` is
|
|
228
|
+
* `Kim Oak`). English and German.
|
|
229
|
+
*/
|
|
230
|
+
export const SENTENCE_WORDS = new Set(
|
|
231
|
+
(
|
|
232
|
+
"a an the and but or so then also if when while after before with without from to for of on in at by " +
|
|
233
|
+
"met meet call called calls spoke speak talked talk sent send emailed email asked ask told tell wrote write " +
|
|
234
|
+
"forwarded forward discussed discuss confirmed confirm agreed agree reached reach left visited visit see saw " +
|
|
235
|
+
"he she they we i you it his her their our my your this that these those there here per re fyi note notes " +
|
|
236
|
+
"der die das ein eine und oder mit von zu für am im bei nach vor er sie wir ich es"
|
|
237
|
+
).split(" "),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Whether a span is written like a name or a key, so it is kept as a
|
|
242
|
+
* mention when the gazetteer finds nothing: an email, a web address or a
|
|
243
|
+
* phone number; or two to four words of letters, each with a capital or an
|
|
244
|
+
* initial (a name particle between them), at least one a full word.
|
|
245
|
+
*/
|
|
246
|
+
export function nameShaped(span: string): boolean {
|
|
247
|
+
const parts = tokens(span).map((t) => t.text);
|
|
248
|
+
if (parts.length === 1) return keyShaped(parts[0]!) !== null && keyShaped(parts[0]!) !== "code";
|
|
249
|
+
if (parts.length < 2 || parts.length > 4) return false;
|
|
250
|
+
if (SENTENCE_WORDS.has(parts[0]!.toLowerCase())) return false;
|
|
251
|
+
const inner = (i: number) => i > 0 && i < parts.length - 1;
|
|
252
|
+
return (
|
|
253
|
+
parts.every(
|
|
254
|
+
(p, i) =>
|
|
255
|
+
isLetterWord(p) && (capitalized(p) || isInitial(p) || (inner(i) && PARTICLES.has(p))),
|
|
256
|
+
) && parts.some((p) => !isInitial(p) && [...p.replace(/\./g, "")].length >= 2)
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The keys of a span that the gazetteer looks up: its strong keys, its
|
|
262
|
+
* whole name (`name:`), and for a span of two words its initials forms.
|
|
263
|
+
* The initials forms of a longer span would match names it only holds part of.
|
|
264
|
+
*/
|
|
265
|
+
export function lookupKeys(span: string): string[] {
|
|
266
|
+
const words = tokens(span).length;
|
|
267
|
+
return blockKeys(span, "text").filter(
|
|
268
|
+
(key) =>
|
|
269
|
+
keyWeight(key) === "strong" ||
|
|
270
|
+
(key.startsWith("name:") &&
|
|
271
|
+
keyWeight(key) === "medium" &&
|
|
272
|
+
(words === 2 || !key.startsWith("name:initial:"))),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The sentence that holds a span, at most 300 characters: the span and the
|
|
278
|
+
* text around it, cut at sentence ends.
|
|
279
|
+
*/
|
|
280
|
+
export function windowOf(text: string, offset: number, length: number): string {
|
|
281
|
+
const before = text.slice(0, offset);
|
|
282
|
+
const after = text.slice(offset + length);
|
|
283
|
+
const start = Math.max(before.search(/[.!?\n][^.!?\n]*$/) + 1, 0);
|
|
284
|
+
const endMatch = after.search(/[.!?\n](\s|$)/);
|
|
285
|
+
const end = offset + length + (endMatch < 0 ? after.length : endMatch + 1);
|
|
286
|
+
let from = start;
|
|
287
|
+
let to = end;
|
|
288
|
+
if (to - from > 300) {
|
|
289
|
+
from = Math.max(from, offset - 150 + Math.floor(length / 2));
|
|
290
|
+
to = Math.min(to, from + 300);
|
|
291
|
+
}
|
|
292
|
+
return text.slice(from, to).trim();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The `refers` mentions of one text value: the gazetteer hits (spans with a
|
|
297
|
+
* key in `known`), then the spans written like a name or a key. The longest
|
|
298
|
+
* span wins where two overlap; at most `max` per value (4.4).
|
|
299
|
+
*/
|
|
300
|
+
export function textMentions(
|
|
301
|
+
field: string,
|
|
302
|
+
value: number,
|
|
303
|
+
text: string,
|
|
304
|
+
known: Set<string>,
|
|
305
|
+
max = 100,
|
|
306
|
+
): FoundMention[] {
|
|
307
|
+
const spans = textSpans(text);
|
|
308
|
+
const hits = spans.filter((s) => lookupKeys(s.span).some((k) => known.has(k)));
|
|
309
|
+
const shaped = spans.filter((s) => nameShaped(s.span));
|
|
310
|
+
const chosen: { span: TextSpan; found: FoundMention["found"] }[] = [];
|
|
311
|
+
const free = (s: TextSpan) =>
|
|
312
|
+
chosen.every(
|
|
313
|
+
({ span: c }) => s.offset + s.length <= c.offset || c.offset + c.length <= s.offset,
|
|
314
|
+
);
|
|
315
|
+
const longestFirst = (a: TextSpan, b: TextSpan) => b.length - a.length || a.offset - b.offset;
|
|
316
|
+
for (const s of [...hits].sort(longestFirst))
|
|
317
|
+
if (free(s)) chosen.push({ span: s, found: "gazetteer" });
|
|
318
|
+
for (const s of [...shaped].sort(longestFirst))
|
|
319
|
+
if (free(s)) chosen.push({ span: s, found: "shape" });
|
|
320
|
+
chosen.sort((a, b) => a.span.offset - b.span.offset);
|
|
321
|
+
const counts = new Map<string, number>();
|
|
322
|
+
return chosen.slice(0, max).map(({ span: s, found }) => {
|
|
323
|
+
const seen = counts.get(s.span) ?? 0;
|
|
324
|
+
counts.set(s.span, seen + 1);
|
|
325
|
+
return {
|
|
326
|
+
field,
|
|
327
|
+
role: "refers" as const,
|
|
328
|
+
kind: keyShaped(s.span) ?? "text",
|
|
329
|
+
span: s.span,
|
|
330
|
+
value,
|
|
331
|
+
offset: s.offset,
|
|
332
|
+
length: s.length,
|
|
333
|
+
occurrence: seen,
|
|
334
|
+
window: windowOf(text, s.offset, s.length),
|
|
335
|
+
found,
|
|
336
|
+
};
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The keys the gazetteer asks the graph about for these texts, each once. */
|
|
341
|
+
export function gazetteerKeys(texts: string[]): string[] {
|
|
342
|
+
const keys = new Set<string>();
|
|
343
|
+
for (const text of texts)
|
|
344
|
+
for (const s of textSpans(text)) lookupKeys(s.span).forEach((k) => keys.add(k));
|
|
345
|
+
return [...keys];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- the reasoning model, for the fields that opt in -----------------------------
|
|
349
|
+
|
|
350
|
+
const EXTRACT_PROMPT =
|
|
351
|
+
"Below is the text of one field of a record. List every span that names a person, an organization, a product, a place, a document or a code. Copy each span exactly as the text writes it. Give each name once per place it is written. Do not list pronouns, plain numbers, prices, dates or common words.";
|
|
352
|
+
|
|
353
|
+
/** The model pass of one text value, for a field that opts in. */
|
|
354
|
+
export function extractRequest(record: SourceRecord, field: string, text: string): ReasonRequest {
|
|
355
|
+
return {
|
|
356
|
+
system: "You read records of a knowledge graph and find the names of things in their text.",
|
|
357
|
+
prompt: `${EXTRACT_PROMPT}\n\nRecord: ${record.class} "${record.name}", field ${field}.\n\n${text}`,
|
|
358
|
+
schema: {
|
|
359
|
+
type: "object",
|
|
360
|
+
additionalProperties: false,
|
|
361
|
+
required: ["spans"],
|
|
362
|
+
properties: {
|
|
363
|
+
spans: {
|
|
364
|
+
type: "array",
|
|
365
|
+
items: {
|
|
366
|
+
type: "object",
|
|
367
|
+
additionalProperties: false,
|
|
368
|
+
required: ["span", "kind"],
|
|
369
|
+
properties: {
|
|
370
|
+
span: { type: "string" },
|
|
371
|
+
kind: {
|
|
372
|
+
type: "string",
|
|
373
|
+
enum: ["person", "organization", "product", "place", "document", "code"],
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
max_tokens: 4_000,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The spans of a model answer that the text holds, at places the found
|
|
386
|
+
* mentions do not take yet. Each span is placed at its next free
|
|
387
|
+
* occurrence in the text.
|
|
388
|
+
*/
|
|
389
|
+
export function checkExtracted(
|
|
390
|
+
answer: unknown,
|
|
391
|
+
field: string,
|
|
392
|
+
value: number,
|
|
393
|
+
text: string,
|
|
394
|
+
taken: FoundMention[],
|
|
395
|
+
max = 100,
|
|
396
|
+
): FoundMention[] {
|
|
397
|
+
const list = (answer as { spans?: unknown } | null)?.spans;
|
|
398
|
+
if (!Array.isArray(list)) return [];
|
|
399
|
+
const out: FoundMention[] = [];
|
|
400
|
+
const overlaps = (offset: number, length: number) =>
|
|
401
|
+
[...taken, ...out].some((m) => offset < m.offset + m.length && m.offset < offset + length);
|
|
402
|
+
for (const item of list) {
|
|
403
|
+
const span = typeof item?.span === "string" ? item.span.normalize("NFC").trim() : "";
|
|
404
|
+
if (!span || span.length > 200) continue;
|
|
405
|
+
let at = text.indexOf(span);
|
|
406
|
+
while (at >= 0 && overlaps(at, span.length)) at = text.indexOf(span, at + 1);
|
|
407
|
+
if (at < 0) continue;
|
|
408
|
+
const occurrence = [...text.slice(0, at).matchAll(new RegExp(escape(span), "g"))].length;
|
|
409
|
+
out.push({
|
|
410
|
+
field,
|
|
411
|
+
role: "refers",
|
|
412
|
+
kind: keyShaped(span) ?? "text",
|
|
413
|
+
span,
|
|
414
|
+
value,
|
|
415
|
+
offset: at,
|
|
416
|
+
length: span.length,
|
|
417
|
+
occurrence,
|
|
418
|
+
window: windowOf(text, at, span.length),
|
|
419
|
+
found: "model",
|
|
420
|
+
});
|
|
421
|
+
if (out.length + taken.length >= max) break;
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
427
|
+
|
|
428
|
+
/** Two spans the same after folding: the gazetteer compares spans this way. */
|
|
429
|
+
export const sameSpan = (a: string, b: string) => fold(a).trim() === fold(b).trim();
|