@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,203 @@
|
|
|
1
|
+
# link
|
|
2
|
+
|
|
3
|
+
Connects the mentions of one thing across the records of a graph. A
|
|
4
|
+
contact, a lead, a deal and a note can each name one person in a different
|
|
5
|
+
way: `Mira Tannenholt-Ruiz`, `Mira Ruiz`, `M. Tannenholt`, `Mimi T.`. The
|
|
6
|
+
workflow groups those mentions into one `LinkedThing`. The links are
|
|
7
|
+
references across the graph, and later checks compare what each source says
|
|
8
|
+
about the thing.
|
|
9
|
+
|
|
10
|
+
This is phase L1 of the design
|
|
11
|
+
[general linking](../../../docs/architecture/general-linking.md): identities
|
|
12
|
+
on records. Documents (pages) come in phase L2.
|
|
13
|
+
|
|
14
|
+
## What it writes
|
|
15
|
+
|
|
16
|
+
| Term | Kind | Holds |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `Mention` | class | `span`, `role` (`self` or `refers`), `kind`, `field`, `offset`, `length`, `block_key` (several), `window` (the sentence of a span of text); the links and constraints `linked_to`, `must_link` and `cannot_link` |
|
|
19
|
+
| `LinkedThing` | class | its name (the anchor's name), `kind`, `anchor_key`, `alias` (several: the other names its members use) |
|
|
20
|
+
| `MENTIONED_IN` | relation, mention to record | the record that holds the mention |
|
|
21
|
+
| `SAME_THING_AS` | relation, mention to mention | a decided or accepted pair, with its confidence and its evidence (the sentence and the record's name) |
|
|
22
|
+
| `REFERS_TO` | relation, mention to `LinkedThing` | the thing the mention is in |
|
|
23
|
+
| `HAS_RECORD` | relation, `LinkedThing` to record | the records whose names and keys are in the thing |
|
|
24
|
+
| `MERGED_INTO` | relation, `LinkedThing` to `LinkedThing` | where an old thing is now: a thing whose anchor changed, or that merged |
|
|
25
|
+
| `SAME_AS` | relation, record to record | two records of one thing: a person writes it when they accept an identity |
|
|
26
|
+
|
|
27
|
+
A record's own names and keys are `self` mentions. Each record anchors a
|
|
28
|
+
thing. A span in a text field that names something is a `refers` mention.
|
|
29
|
+
|
|
30
|
+
## How it links
|
|
31
|
+
|
|
32
|
+
1. **Mentions.** A `name` or `key:*` field gives one `self` mention per
|
|
33
|
+
value. A `text` field gives `refers` mentions: first the gazetteer pass,
|
|
34
|
+
which keeps each span whose name or key a mention of the graph has; then
|
|
35
|
+
the spans written like a name or a key (`M. Tannenholt`, an email).
|
|
36
|
+
2. **Block keys.** Each mention gets normal forms of its name or key as
|
|
37
|
+
graph facts (`block_key`): the sorted name, the initials forms, the name
|
|
38
|
+
parts and their sound, an email, a web address, a code. A key that more
|
|
39
|
+
than 200 mentions hold is a stop key and finds nothing.
|
|
40
|
+
3. **Candidates.** The mentions that share a key, and the things of the
|
|
41
|
+
records within two hops (the deal that a note is about). At most 30, the
|
|
42
|
+
ones with a strong key first.
|
|
43
|
+
4. **Decision.** See the next section.
|
|
44
|
+
5. **Things.** The things identity groups the decided pairs. A person's
|
|
45
|
+
decisions are constraints. Two records are never one thing until a person
|
|
46
|
+
accepts their identity.
|
|
47
|
+
|
|
48
|
+
The thing's IRI is a hash of the graph and its anchor's key. The anchor is a
|
|
49
|
+
record's name first, then any other mention. So the same inputs give the
|
|
50
|
+
same IRIs. A thing whose anchor changes keeps one statement, `MERGED_INTO`
|
|
51
|
+
the thing that holds its members now.
|
|
52
|
+
|
|
53
|
+
## Model reads of record values
|
|
54
|
+
|
|
55
|
+
`modelsReadRecords` is off by default (general linking 12, decision 1).
|
|
56
|
+
|
|
57
|
+
- **Off.** No model reads a record value. A strong key (an email, a web
|
|
58
|
+
address, a phone number, a handle, a code) links at confidence 1. Every
|
|
59
|
+
other candidate goes to a person, grouped by thing.
|
|
60
|
+
- **On.** The decision model reads the names, keys and context fields of
|
|
61
|
+
the records. It answers a kind question and a names question per mention,
|
|
62
|
+
and a same-record question per pair of records. A link at or above `gate`
|
|
63
|
+
(0.8) is written; a link below it goes to a person. The reasoning model
|
|
64
|
+
reads only the text fields that opt in: `{ "role": "text", "extract": true }`
|
|
65
|
+
(decision 8).
|
|
66
|
+
|
|
67
|
+
Every model call has an effect id made from its request. The same question
|
|
68
|
+
gets its first answer from the effect cache, at no cost.
|
|
69
|
+
|
|
70
|
+
## Review items
|
|
71
|
+
|
|
72
|
+
| Item | One per | Accept writes |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| Link (`starter:<name>:link:…`) | Thing | The kept `SAME_THING_AS` rows. A row left out keeps its mention apart from the thing. |
|
|
75
|
+
| No link (`starter:<name>:nolink:…`) | Thing of the best candidate | The kept rows: the model chose no thing, and a person disagrees. |
|
|
76
|
+
| Identity (`starter:<name>:identity:…`) | Group of 2 to 10 records | `SAME_AS` between the records, and `SAME_THING_AS` between their names. |
|
|
77
|
+
|
|
78
|
+
A person always accepts an identity (decision 2). The `identityGate` (0.99)
|
|
79
|
+
only orders and groups the identity items (`mapping.identity.above_gate`).
|
|
80
|
+
Each item has the origin `starter:<name>` with the label `link`, so the
|
|
81
|
+
inbox labels and patterns of the producer `link` read it.
|
|
82
|
+
|
|
83
|
+
A person's decisions feed back. A kept row is a must-link. A row left out,
|
|
84
|
+
or a dismissed item, is a cannot-link. No model answer joins what a
|
|
85
|
+
cannot-link keeps apart.
|
|
86
|
+
|
|
87
|
+
## Settings
|
|
88
|
+
|
|
89
|
+
| Setting | Default | Meaning |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `name` | `link` | The named workflows are `<name>-<class>` and `<name>-reviews`; the things identity is `<name>-things` |
|
|
92
|
+
| `fields` | the samples' CRM | The role of each field of each class (below) |
|
|
93
|
+
| `gate` | 0.8 | A link at or above it is written, with model reads on |
|
|
94
|
+
| `identityGate` | 0.99 | Orders and groups the identity items |
|
|
95
|
+
| `modelsReadRecords` | `false` | Whether models may read record values |
|
|
96
|
+
| `shards` | 4 | Source shards of each named workflow |
|
|
97
|
+
| `sourcesPerTurn` | 16 | Records per message |
|
|
98
|
+
| `textPerTurn` | 8,000 | Characters of text per turn; the rest continues in a new turn |
|
|
99
|
+
|
|
100
|
+
| Field role | Gives |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `name` | One `self` mention per value |
|
|
103
|
+
| `name:given`, `name:family` | One `self` mention of the two joined (`first_name+last_name`) |
|
|
104
|
+
| `key:email`, `key:phone`, `key:web`, `key:handle`, `key:code` | One `self` mention per value, with a strong key |
|
|
105
|
+
| `text`, `{ "role": "text", "extract": true }` | `refers` mentions found in the text |
|
|
106
|
+
| `context` | No mention; read for the decisions |
|
|
107
|
+
|
|
108
|
+
A field named `name` (or `label`) is the record's display name, which the
|
|
109
|
+
published graph holds as `rdfs:label`; the workflow watches it as `label`.
|
|
110
|
+
Any other field is a property of the record.
|
|
111
|
+
|
|
112
|
+
## Run it
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npm create @littlebigbrain -- --template link my-links
|
|
116
|
+
cd my-links && npm install
|
|
117
|
+
npm test # the workflow on the samples, with mock models
|
|
118
|
+
LBB_GRAPH=try-link npm run try # the samples on a graph of your stack
|
|
119
|
+
LBB_GRAPH=try-link npm start # serve it
|
|
120
|
+
LBB_GRAPH=try-link npm run grade -- --gold samples/gold.json
|
|
121
|
+
LBB_GRAPH=crm npm run setup && npm start # your graph
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`npm run setup` creates the classes and properties, one named workflow per
|
|
125
|
+
class of the settings, the named workflow that watches accepted items, and
|
|
126
|
+
the things identity. It is safe to repeat. The workflow is for a records
|
|
127
|
+
graph. Do not run it on a graph where `documents.link` runs: both write
|
|
128
|
+
`MENTIONED_IN` and `REFERS_TO`.
|
|
129
|
+
|
|
130
|
+
## Grade a run
|
|
131
|
+
|
|
132
|
+
`npm run grade` (`src/grade-run.ts`) prints the metrics of general linking
|
|
133
|
+
9.2: link precision at the gate and its 95% Wilson interval, pairwise
|
|
134
|
+
precision and recall over things, cluster purity and completeness
|
|
135
|
+
(B-cubed), the review share, and identity precision and recall.
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
LBB_URL=... LBB_API_KEY=... LBB_GRAPH=crm npm run grade -- --gold .lbb-datasets/link-eval/gold.json --save run.json
|
|
139
|
+
npm run grade -- --gold .lbb-datasets/link-eval/gold.json --run run.json
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The gold file (`lbb.link.gold.v1`) lists the groups. Each group is one real
|
|
143
|
+
thing, with the mentions that name it:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"format": "lbb.link.gold.v1",
|
|
148
|
+
"complete": false,
|
|
149
|
+
"records": { "contact-1": "https://littlebigbrain.com/e/…" },
|
|
150
|
+
"groups": [
|
|
151
|
+
{ "id": "mira", "kind": "person", "mentions": [
|
|
152
|
+
{ "record": "contact-1", "field": "name", "span": "Mira Tannenholt-Ruiz" },
|
|
153
|
+
{ "record": "https://littlebigbrain.com/e/…", "field": "body", "span": "Mimi T.", "occurrence": 0 }
|
|
154
|
+
] }
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A mention names its record (an IRI, or a name of `records`), its field
|
|
160
|
+
(`first_name+last_name` for a joined name), its span as the field writes it,
|
|
161
|
+
and which equal span of the field it is (`occurrence`, default 0). With
|
|
162
|
+
`complete: true`, a mention that the gold does not list is a thing of its
|
|
163
|
+
own. Without it, a link to such a mention counts apart as unjudged. Keep a
|
|
164
|
+
gold file of real records out of git, under `.lbb-datasets/`.
|
|
165
|
+
|
|
166
|
+
## The samples
|
|
167
|
+
|
|
168
|
+
`samples/records.json` is an invented CRM: 7 organizations, 12 contacts, 5
|
|
169
|
+
leads, 7 deals and 11 notes. Every name, company and address is invented.
|
|
170
|
+
The names vary: a nickname (`Bob` for `Robert`), initials (`M. Tannenholt`,
|
|
171
|
+
`F. Okafor`), accents and transliterations (`Jürgen`, `Juergen`, `Jurgen`;
|
|
172
|
+
`Søren`, `Soren`; `Zoë`, `Zoe`), name order (`Wren, Tobias`), a typo
|
|
173
|
+
(`Ramaswamey`), a person known only by an email, a surname that two people
|
|
174
|
+
share (`Okafor`), two people of one name at two organizations (`Alex
|
|
175
|
+
Brandt`), and company domains. `samples/gold.json` holds the gold groups.
|
|
176
|
+
|
|
177
|
+
## How the template differs from the design
|
|
178
|
+
|
|
179
|
+
- A named workflow takes one watch trigger in this release, so each class is
|
|
180
|
+
a named workflow of its own. All of them run the workflow type `link`.
|
|
181
|
+
- The candidates by the name index and by meaning wait for work tokens that
|
|
182
|
+
reach those routes (N3). The keys and the neighbours find the candidates.
|
|
183
|
+
- A source turn sends its decided pairs in messages of at most 24,000
|
|
184
|
+
bytes; a larger message goes in parts (`.1`, `.2`, …) until blobs (G11).
|
|
185
|
+
- The things identity is an engine identity that `npm run setup` creates.
|
|
186
|
+
It reads the review items it filed to learn a person's decisions.
|
|
187
|
+
|
|
188
|
+
## Files
|
|
189
|
+
|
|
190
|
+
| File | Holds |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `workflow.ts` | The workflow, the named workflows (`workflows()`), the ontology, the ports (`linkPorts()`) and `setup()` |
|
|
193
|
+
| `settings.ts` | The settings and the names of the parts, and the decided message |
|
|
194
|
+
| `names.ts` | Normal forms and block keys |
|
|
195
|
+
| `candidates.ts` | The candidate reads, the scoring and the bounds |
|
|
196
|
+
| `decide.ts` | The questions, the packing, the link rule and the rules without models |
|
|
197
|
+
| `things.ts` | The grouping with constraints, anchors, IRIs, merges and splits |
|
|
198
|
+
| `sources/records.ts` | Field roles, and the mentions of record fields |
|
|
199
|
+
| `source-turn.ts`, `things-turn.ts` | The turns of a source shard and of the things identity |
|
|
200
|
+
| `items.ts`, `reads.ts` | The review items, and the reads of the graph |
|
|
201
|
+
| `grade.ts`, `grade-run.ts` | The grading, and its command |
|
|
202
|
+
| `fixture.ts` | The test support: the graph in memory, mock models and ports, a reviewer that follows a gold file |
|
|
203
|
+
| `setup.ts`, `worker.ts`, `try.ts`, `samples.ts` | The commands |
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// The candidates of a mention (general linking 4.3) and their bounds (4.4).
|
|
2
|
+
// The queries are built here and run by the workflow; the scoring is pure.
|
|
3
|
+
//
|
|
4
|
+
// Block keys are graph facts: `block_key` values on each `Mention`. One read
|
|
5
|
+
// counts the mentions per key, so a key that more than 200 mentions hold is
|
|
6
|
+
// a stop key and finds nothing. A second read returns the mentions of the
|
|
7
|
+
// other keys, with the thing each one belongs to. The neighbours are the
|
|
8
|
+
// things of the records within two hops of the mention's source.
|
|
9
|
+
import { KEY_KINDS, keyKind, keyWeight, nameSimilarity, type KeyKind } from "./names.js";
|
|
10
|
+
|
|
11
|
+
/** The bounds of the candidate sources and of a mention (4.4). */
|
|
12
|
+
export const BOUNDS = {
|
|
13
|
+
/** Candidates with an equal strong key, per mention. */
|
|
14
|
+
strong: 10,
|
|
15
|
+
/** Candidates that share a medium or weak key, per mention. */
|
|
16
|
+
other: 10,
|
|
17
|
+
/** Candidates among the neighbours, per mention. */
|
|
18
|
+
neighbours: 10,
|
|
19
|
+
/** Records the neighbours read reads, per source turn. */
|
|
20
|
+
neighbourRecords: 50,
|
|
21
|
+
/** Hops from a mention's source to its neighbours. */
|
|
22
|
+
hops: 2,
|
|
23
|
+
/** Candidates of one mention, from every source. */
|
|
24
|
+
total: 30,
|
|
25
|
+
/** A key that more mentions hold is a stop key. */
|
|
26
|
+
stopKey: 200,
|
|
27
|
+
/** Mentions one text value gives. */
|
|
28
|
+
textMentions: 100,
|
|
29
|
+
/** Rows one key read returns. */
|
|
30
|
+
keyRows: 4_000,
|
|
31
|
+
/** Keys one read asks about. */
|
|
32
|
+
keysPerRead: 500,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The lowest score a candidate found by a medium or weak key keeps. */
|
|
36
|
+
export const MIN_SCORE = 0.55;
|
|
37
|
+
/** The lowest name similarity a neighbour keeps. */
|
|
38
|
+
export const MIN_NEIGHBOUR_SIMILARITY = 0.6;
|
|
39
|
+
|
|
40
|
+
export const P =
|
|
41
|
+
"PREFIX p: <https://littlebigbrain.com/p/>\nPREFIX r: <https://littlebigbrain.com/r/>\nPREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>\n";
|
|
42
|
+
/** A record's display name, and an entity's name, in the published RDF. */
|
|
43
|
+
export const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
|
44
|
+
export const RELATION_IRI = "https://littlebigbrain.com/r/";
|
|
45
|
+
export const PROPERTY_IRI = "https://littlebigbrain.com/p/";
|
|
46
|
+
export const CLASS_IRI = "https://littlebigbrain.com/class/";
|
|
47
|
+
|
|
48
|
+
/** The local part of a class, property or relation IRI, normalized and escaped as the server mints it. */
|
|
49
|
+
export function iriSegment(name: string): string {
|
|
50
|
+
const normalized = name.trim().toLowerCase().split(/\s+/).join(" ");
|
|
51
|
+
let out = "";
|
|
52
|
+
for (const byte of new TextEncoder().encode(normalized)) {
|
|
53
|
+
const char = String.fromCharCode(byte);
|
|
54
|
+
out += /[A-Za-z0-9\-_.~]/.test(char)
|
|
55
|
+
? char
|
|
56
|
+
: `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const classIri = (name: string) => `${CLASS_IRI}${iriSegment(name)}`;
|
|
62
|
+
export const propertyIri = (name: string) => `${PROPERTY_IRI}${iriSegment(name)}`;
|
|
63
|
+
export const relationIri = (name: string) => `${RELATION_IRI}${iriSegment(name)}`;
|
|
64
|
+
|
|
65
|
+
/** IRIs as a VALUES list; an IRI a query cannot hold is left out. */
|
|
66
|
+
export const iris = (list: string[]) =>
|
|
67
|
+
list
|
|
68
|
+
.filter((iri) => /^[^\s<>"{}|\\^`]+$/.test(iri))
|
|
69
|
+
.map((iri) => `<${iri}>`)
|
|
70
|
+
.join(" ");
|
|
71
|
+
|
|
72
|
+
/** Strings as a VALUES list of literals. */
|
|
73
|
+
export const literals = (list: string[]) => list.map((s) => JSON.stringify(s)).join(" ");
|
|
74
|
+
|
|
75
|
+
/** The relations of the link vocabulary: a neighbour is never found through them. */
|
|
76
|
+
export const LINK_RELATIONS = [
|
|
77
|
+
"mentioned_in",
|
|
78
|
+
"refers_to",
|
|
79
|
+
"same_thing_as",
|
|
80
|
+
"has_record",
|
|
81
|
+
"merged_into",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// ---- the reads ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** How many mentions hold each key. */
|
|
87
|
+
export function keyCountsQuery(keys: string[]): string {
|
|
88
|
+
return `# link: key counts\n${P}SELECT ?key (COUNT(?m) AS ?n) WHERE {
|
|
89
|
+
VALUES ?key { ${literals(keys)} }
|
|
90
|
+
?m p:block_key ?key .
|
|
91
|
+
} GROUP BY ?key`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseCounts(rows: Record<string, string>[]): Map<string, number> {
|
|
95
|
+
return new Map(rows.map((r) => [r.key!, Number(r.n ?? 0)]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The keys that more than `limit` mentions hold. */
|
|
99
|
+
export function stopKeys(counts: Map<string, number>, limit = BOUNDS.stopKey): string[] {
|
|
100
|
+
return [...counts]
|
|
101
|
+
.filter(([, n]) => n > limit)
|
|
102
|
+
.map(([key]) => key)
|
|
103
|
+
.sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The mentions that hold the keys, with what they belong to. */
|
|
107
|
+
export function keyHitsQuery(keys: string[]): string {
|
|
108
|
+
return `# link: keys\n${P}SELECT ?key ?m ?role ?span ?src ?kind ?window ?thing ?label WHERE {
|
|
109
|
+
VALUES ?key { ${literals(keys)} }
|
|
110
|
+
?m p:block_key ?key ; p:role ?role ; p:span ?span ; r:mentioned_in ?src .
|
|
111
|
+
OPTIONAL { ?m p:kind ?kind } OPTIONAL { ?m p:window ?window }
|
|
112
|
+
OPTIONAL { ?m r:refers_to ?thing . OPTIONAL { ?thing rdfs:label ?label } }
|
|
113
|
+
} LIMIT ${BOUNDS.keyRows}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A mention that holds a looked-up key. */
|
|
117
|
+
export interface KeyHit {
|
|
118
|
+
key: string;
|
|
119
|
+
mention: string;
|
|
120
|
+
role: string;
|
|
121
|
+
span: string;
|
|
122
|
+
source: string;
|
|
123
|
+
kind: string | null;
|
|
124
|
+
window: string | null;
|
|
125
|
+
thing: string | null;
|
|
126
|
+
label: string | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseHits(rows: Record<string, string>[]): KeyHit[] {
|
|
130
|
+
return rows
|
|
131
|
+
.filter((r) => r.key && r.m && r.src)
|
|
132
|
+
.map((r) => ({
|
|
133
|
+
key: r.key!,
|
|
134
|
+
mention: r.m!,
|
|
135
|
+
role: r.role ?? "refers",
|
|
136
|
+
span: r.span ?? "",
|
|
137
|
+
source: r.src!,
|
|
138
|
+
kind: r.kind ?? null,
|
|
139
|
+
window: r.window ?? null,
|
|
140
|
+
thing: r.thing ?? null,
|
|
141
|
+
label: r.label ?? null,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One hop of relations from records, both ways, without the link vocabulary. */
|
|
146
|
+
export function hopQuery(records: string[]): string {
|
|
147
|
+
const skip = LINK_RELATIONS.map((r) => `r:${r}`).join(", ");
|
|
148
|
+
return `# link: hop\n${P}SELECT ?a ?r ?b ?dir ?name ?class WHERE {
|
|
149
|
+
VALUES ?a { ${iris(records)} }
|
|
150
|
+
{ ?a ?r ?b BIND("out" AS ?dir) } UNION { ?b ?r ?a BIND("in" AS ?dir) }
|
|
151
|
+
FILTER(STRSTARTS(STR(?r), "${RELATION_IRI}") && ?r NOT IN (${skip}))
|
|
152
|
+
OPTIONAL { ?b rdfs:label ?name } OPTIONAL { ?b a ?class }
|
|
153
|
+
} LIMIT 500`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A relation from a record to a neighbour. */
|
|
157
|
+
export interface Hop {
|
|
158
|
+
from: string;
|
|
159
|
+
relation: string;
|
|
160
|
+
to: string;
|
|
161
|
+
dir: "out" | "in";
|
|
162
|
+
name: string;
|
|
163
|
+
class: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const local = (iri: string, base: string) =>
|
|
167
|
+
iri.startsWith(base) ? decodeURIComponent(iri.slice(base.length)) : iri;
|
|
168
|
+
|
|
169
|
+
export function parseHops(rows: Record<string, string>[]): Hop[] {
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
const out: Hop[] = [];
|
|
172
|
+
for (const r of rows) {
|
|
173
|
+
if (!r.a || !r.r || !r.b) continue;
|
|
174
|
+
const key = `${r.a}\u0000${r.r}\u0000${r.b}\u0000${r.dir}`;
|
|
175
|
+
if (seen.has(key)) continue;
|
|
176
|
+
seen.add(key);
|
|
177
|
+
out.push({
|
|
178
|
+
from: r.a,
|
|
179
|
+
relation: local(r.r, RELATION_IRI).toUpperCase(),
|
|
180
|
+
to: r.b,
|
|
181
|
+
dir: r.dir === "in" ? "in" : "out",
|
|
182
|
+
name: r.name ?? "",
|
|
183
|
+
class: r.class ? local(r.class, CLASS_IRI) : "",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The mentions of the neighbour records, with the things they belong to. */
|
|
190
|
+
export function neighbourMentionsQuery(records: string[]): string {
|
|
191
|
+
return `# link: neighbour mentions\n${P}SELECT ?n ?m ?role ?span ?thing ?label WHERE {
|
|
192
|
+
VALUES ?n { ${iris(records)} }
|
|
193
|
+
?m r:mentioned_in ?n ; p:role ?role ; p:span ?span .
|
|
194
|
+
OPTIONAL { ?m r:refers_to ?thing . OPTIONAL { ?thing rdfs:label ?label } }
|
|
195
|
+
} LIMIT 2000`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A mention of a neighbour record, and how the neighbour is reached from the source. */
|
|
199
|
+
export interface NeighbourMention {
|
|
200
|
+
record: string;
|
|
201
|
+
mention: string;
|
|
202
|
+
role: string;
|
|
203
|
+
span: string;
|
|
204
|
+
thing: string | null;
|
|
205
|
+
label: string | null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function parseNeighbourMentions(rows: Record<string, string>[]): NeighbourMention[] {
|
|
209
|
+
return rows
|
|
210
|
+
.filter((r) => r.n && r.m)
|
|
211
|
+
.map((r) => ({
|
|
212
|
+
record: r.n!,
|
|
213
|
+
mention: r.m!,
|
|
214
|
+
role: r.role ?? "refers",
|
|
215
|
+
span: r.span ?? "",
|
|
216
|
+
thing: r.thing ?? null,
|
|
217
|
+
label: r.label ?? null,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The records within `hops` of each source, and how each is reached: a
|
|
223
|
+
* breadth-first walk over `hops` (one read per hop), at most `limit`
|
|
224
|
+
* records. A neighbour's path reads `ABOUT → Deal "…" → INVOLVES`.
|
|
225
|
+
*/
|
|
226
|
+
export function neighbourPaths(
|
|
227
|
+
sources: string[],
|
|
228
|
+
hopsByLevel: Hop[][],
|
|
229
|
+
limit = BOUNDS.neighbourRecords,
|
|
230
|
+
): Map<string, Map<string, string>> {
|
|
231
|
+
const out = new Map<string, Map<string, string>>();
|
|
232
|
+
let total = 0;
|
|
233
|
+
for (const source of sources) {
|
|
234
|
+
const reached = new Map<string, string>([[source, ""]]);
|
|
235
|
+
let frontier = [source];
|
|
236
|
+
for (const level of hopsByLevel) {
|
|
237
|
+
const next: string[] = [];
|
|
238
|
+
for (const hop of level) {
|
|
239
|
+
if (!frontier.includes(hop.from) || reached.has(hop.to) || total >= limit) continue;
|
|
240
|
+
const before = reached.get(hop.from)!;
|
|
241
|
+
const step = `${hop.relation}${hop.dir === "in" ? " (from)" : ""} → ${hop.class || "record"} "${hop.name.slice(0, 60)}"`;
|
|
242
|
+
reached.set(hop.to, before ? `${before} → ${step}` : step);
|
|
243
|
+
next.push(hop.to);
|
|
244
|
+
total += 1;
|
|
245
|
+
}
|
|
246
|
+
frontier = next;
|
|
247
|
+
}
|
|
248
|
+
reached.delete(source);
|
|
249
|
+
out.set(source, reached);
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- candidates -------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
/** A mention whose candidates are wanted. */
|
|
257
|
+
export interface MentionInput {
|
|
258
|
+
iri: string;
|
|
259
|
+
source: string;
|
|
260
|
+
role: "self" | "refers";
|
|
261
|
+
span: string;
|
|
262
|
+
keys: string[];
|
|
263
|
+
/** Mentions a person keeps apart from this one. */
|
|
264
|
+
cannot: string[];
|
|
265
|
+
/** The thing the mention is in now: for a self mention, what it holds is no candidate (it is linked already). */
|
|
266
|
+
thing?: string | null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** One candidate of a mention: a thing, or a lone mention. */
|
|
270
|
+
export interface Candidate {
|
|
271
|
+
/** The mention a link pairs with: the one that matched, or a member of the neighbour thing. */
|
|
272
|
+
target: string;
|
|
273
|
+
/** The thing the target belongs to; null for a lone mention. */
|
|
274
|
+
thing: string | null;
|
|
275
|
+
label: string;
|
|
276
|
+
/** Up to three spans of the candidate's members that the reads returned. */
|
|
277
|
+
spans: string[];
|
|
278
|
+
/** The records whose self mentions are among those members. */
|
|
279
|
+
records: string[];
|
|
280
|
+
score: number;
|
|
281
|
+
/** The key kind that found it, or `neighbour`. */
|
|
282
|
+
found: KeyKind | "neighbour";
|
|
283
|
+
strong: boolean;
|
|
284
|
+
/** For a neighbour: how it is reached from the mention's source. */
|
|
285
|
+
via?: string;
|
|
286
|
+
/** For a lone mention of text: its sentence. */
|
|
287
|
+
window?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Group key of a hit: its thing, else the mention itself. */
|
|
291
|
+
const groupOf = (thing: string | null, mention: string) => thing ?? mention;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The candidates of a mention: the things and lone mentions with an equal
|
|
295
|
+
* strong key (up to 10), the ones that share a medium or weak key, scored
|
|
296
|
+
* by the key weights and the name similarity (up to 10), and the things of
|
|
297
|
+
* the neighbours that are named alike (up to 10); at most 30, the strong
|
|
298
|
+
* ones first. The mention itself, the other self mentions of its record,
|
|
299
|
+
* and what a person keeps apart from it are left out.
|
|
300
|
+
*/
|
|
301
|
+
export function candidatesFor(
|
|
302
|
+
m: MentionInput,
|
|
303
|
+
hits: KeyHit[],
|
|
304
|
+
stop: Set<string>,
|
|
305
|
+
neighbours: { mention: NeighbourMention; via: string }[] = [],
|
|
306
|
+
): Candidate[] {
|
|
307
|
+
const own = new Set(m.keys.filter((k) => !stop.has(k)));
|
|
308
|
+
const banned = new Set(m.cannot);
|
|
309
|
+
const groups = new Map<
|
|
310
|
+
string,
|
|
311
|
+
{
|
|
312
|
+
target: string;
|
|
313
|
+
thing: string | null;
|
|
314
|
+
label: string;
|
|
315
|
+
spans: Set<string>;
|
|
316
|
+
records: Set<string>;
|
|
317
|
+
keys: Set<string>;
|
|
318
|
+
window?: string;
|
|
319
|
+
via?: string;
|
|
320
|
+
}
|
|
321
|
+
>();
|
|
322
|
+
const entry = (thing: string | null, mention: string, label: string) => {
|
|
323
|
+
const key = groupOf(thing, mention);
|
|
324
|
+
let g = groups.get(key);
|
|
325
|
+
if (!g) {
|
|
326
|
+
g = { target: mention, thing, label, spans: new Set(), records: new Set(), keys: new Set() };
|
|
327
|
+
groups.set(key, g);
|
|
328
|
+
}
|
|
329
|
+
return g;
|
|
330
|
+
};
|
|
331
|
+
const skip = (mention: string, source: string, role: string) =>
|
|
332
|
+
mention === m.iri ||
|
|
333
|
+
banned.has(mention) ||
|
|
334
|
+
(m.role === "self" && role === "self" && source === m.source);
|
|
335
|
+
for (const hit of hits) {
|
|
336
|
+
if (!own.has(hit.key) || skip(hit.mention, hit.source, hit.role)) continue;
|
|
337
|
+
if (hit.thing && (banned.has(hit.thing) || (m.role === "self" && hit.thing === m.thing)))
|
|
338
|
+
continue;
|
|
339
|
+
const g = entry(hit.thing, hit.mention, hit.label ?? hit.span);
|
|
340
|
+
g.keys.add(hit.key);
|
|
341
|
+
g.spans.add(hit.span);
|
|
342
|
+
if (hit.role === "self") g.records.add(hit.source);
|
|
343
|
+
if (!hit.thing && hit.window) g.window = hit.window;
|
|
344
|
+
// A strong key names its target: the member that holds it.
|
|
345
|
+
if ([...g.keys].some((k) => keyWeight(k) === "strong") && keyWeight(hit.key) === "strong")
|
|
346
|
+
g.target = hit.mention;
|
|
347
|
+
}
|
|
348
|
+
const scored: Candidate[] = [];
|
|
349
|
+
for (const g of groups.values()) {
|
|
350
|
+
const strongKey = [...g.keys].find((k) => keyWeight(k) === "strong");
|
|
351
|
+
const similarity = Math.max(0, ...[...g.spans].map((s) => nameSimilarity(m.span, s)));
|
|
352
|
+
const keyScore = Math.min(
|
|
353
|
+
1,
|
|
354
|
+
[...g.keys].reduce((sum, k) => sum + KEY_KINDS[keyKind(k)!].value, 0),
|
|
355
|
+
);
|
|
356
|
+
const best = [...g.keys].sort(
|
|
357
|
+
(a, b) => KEY_KINDS[keyKind(b)!].value - KEY_KINDS[keyKind(a)!].value,
|
|
358
|
+
)[0]!;
|
|
359
|
+
scored.push({
|
|
360
|
+
target: g.target,
|
|
361
|
+
thing: g.thing,
|
|
362
|
+
label: g.label,
|
|
363
|
+
spans: [...g.spans].slice(0, 3),
|
|
364
|
+
records: [...g.records].sort(),
|
|
365
|
+
score: strongKey ? 1 : round(0.5 * keyScore + 0.5 * similarity),
|
|
366
|
+
found: keyKind(strongKey ?? best)!,
|
|
367
|
+
strong: Boolean(strongKey),
|
|
368
|
+
...(g.window ? { window: g.window } : {}),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const strong = scored
|
|
372
|
+
.filter((c) => c.strong)
|
|
373
|
+
.sort(byScore)
|
|
374
|
+
.slice(0, BOUNDS.strong);
|
|
375
|
+
const other = scored
|
|
376
|
+
.filter((c) => !c.strong && c.score >= MIN_SCORE)
|
|
377
|
+
.sort(byScore)
|
|
378
|
+
.slice(0, BOUNDS.other);
|
|
379
|
+
|
|
380
|
+
// The neighbours: the things (and lone mentions) of the records near the source.
|
|
381
|
+
const near = new Map<string, Candidate>();
|
|
382
|
+
for (const { mention: n, via } of neighbours) {
|
|
383
|
+
if (
|
|
384
|
+
skip(n.mention, n.record, n.role) ||
|
|
385
|
+
(n.thing && (banned.has(n.thing) || (m.role === "self" && n.thing === m.thing)))
|
|
386
|
+
)
|
|
387
|
+
continue;
|
|
388
|
+
if (n.record === m.source) continue;
|
|
389
|
+
const key = groupOf(n.thing, n.mention);
|
|
390
|
+
const similarity = nameSimilarity(m.span, n.span);
|
|
391
|
+
if (similarity < MIN_NEIGHBOUR_SIMILARITY) continue;
|
|
392
|
+
const before = near.get(key);
|
|
393
|
+
const score = round(0.3 + 0.5 * similarity);
|
|
394
|
+
if (before && before.score >= score) continue;
|
|
395
|
+
near.set(key, {
|
|
396
|
+
target: n.mention,
|
|
397
|
+
thing: n.thing,
|
|
398
|
+
label: n.label ?? n.span,
|
|
399
|
+
spans: [n.span],
|
|
400
|
+
records: n.role === "self" ? [n.record] : [],
|
|
401
|
+
score,
|
|
402
|
+
found: "neighbour",
|
|
403
|
+
strong: false,
|
|
404
|
+
via: n.role === "self" ? via : `named in ${via}`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const out: Candidate[] = [];
|
|
408
|
+
const taken = new Map<string, Candidate>();
|
|
409
|
+
for (const c of [...strong, ...other]) {
|
|
410
|
+
out.push(c);
|
|
411
|
+
taken.set(groupOf(c.thing, c.target), c);
|
|
412
|
+
}
|
|
413
|
+
for (const c of [...near.values()].sort(byScore).slice(0, BOUNDS.neighbours)) {
|
|
414
|
+
const key = groupOf(c.thing, c.target);
|
|
415
|
+
const found = taken.get(key);
|
|
416
|
+
if (found) {
|
|
417
|
+
// Found by a key and near the source: both count.
|
|
418
|
+
found.via ??= c.via;
|
|
419
|
+
if (!found.strong) found.score = round(Math.min(1, found.score + 0.05));
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
out.push(c);
|
|
423
|
+
}
|
|
424
|
+
return [...out.filter((c) => c.strong), ...out.filter((c) => !c.strong).sort(byScore)].slice(
|
|
425
|
+
0,
|
|
426
|
+
BOUNDS.total,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const round = (x: number) => Math.round(x * 1000) / 1000;
|
|
431
|
+
const byScore = (a: Candidate, b: Candidate) =>
|
|
432
|
+
b.score - a.score || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0);
|