@littlebigbrain/create 0.0.0-stage → 0.2.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/LICENSE +55 -0
- package/README.md +73 -2
- package/bin/create-littlebigbrain.mjs +9 -0
- package/lib/create.mjs +244 -0
- package/package.json +46 -4
- package/project/setup.ts +18 -0
- package/project/try.ts +30 -0
- package/project/worker.ts +23 -0
- package/templates/_shared/testing.ts +521 -0
- package/templates/documents-link/README.md +146 -0
- package/templates/documents-link/link.ts +580 -0
- package/templates/documents-link/samples/ausstattungsliste.pdf +76 -0
- package/templates/documents-link/samples/baubeschreibung.pdf +54 -0
- package/templates/documents-link/samples/expected.json +404 -0
- package/templates/documents-link/samples/pages/ausstattungsliste.json +1150 -0
- package/templates/documents-link/samples/pages/baubeschreibung.json +802 -0
- package/templates/documents-link/samples/pages/raumblaetter.json +1580 -0
- package/templates/documents-link/samples/raumblaetter.pdf +109 -0
- package/templates/documents-link/samples.ts +31 -0
- package/templates/documents-link/workflow.test.ts +497 -0
- package/templates/documents-link/workflow.ts +467 -0
- 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/ontology-fit/README.md +81 -0
- package/templates/ontology-fit/samples/expected.md +88 -0
- package/templates/ontology-fit/samples/texts.json +22 -0
- package/templates/ontology-fit/samples.ts +40 -0
- package/templates/ontology-fit/workflow.test.ts +270 -0
- package/templates/ontology-fit/workflow.ts +416 -0
- 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/README.md +81 -0
- package/templates/values-extract/samples/expected.json +148 -0
- package/templates/values-extract/samples/records.json +26 -0
- package/templates/values-extract/samples.ts +27 -0
- package/templates/values-extract/workflow.test.ts +279 -0
- package/templates/values-extract/workflow.ts +419 -0
- package/templates/versions.json +3 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// review: decide which fact proposals become facts.
|
|
2
|
+
//
|
|
3
|
+
// A proposer (documents-map, LBB's documents.map, or any workflow of yours) does not write the
|
|
4
|
+
// facts of the graph's own ontology itself: it writes one `FactProposal` per statement, with its
|
|
5
|
+
// confidence and evidence. A triggered workflow of the starter workflow `workflow` watches the
|
|
6
|
+
// proposals' statements and sends the new ones here. For each proposal, once:
|
|
7
|
+
//
|
|
8
|
+
// - refused when the graph cannot hold the statement (an unknown predicate, a value of the wrong
|
|
9
|
+
// type, a new instance without its class, a domain or a range the ends do not fit);
|
|
10
|
+
// - to a person when a rule of `alwaysReview` names it (`new_instance` or a predicate; by default
|
|
11
|
+
// no rule), when it would replace another writer's value or name, or when its confidence is
|
|
12
|
+
// below the gate;
|
|
13
|
+
// - written otherwise: the statement, with the proposal as its evidence (`writeOwned`).
|
|
14
|
+
//
|
|
15
|
+
// The proposals for a person go into one review item per instance (`review`): the instance a
|
|
16
|
+
// proposal is about (its subject, the new object of a link from an instance the graph has, and
|
|
17
|
+
// always the object of the trace). The item's facts are each statement and its proposal's
|
|
18
|
+
// `fact_status: accepted`; a person can leave facts out of the accept, except the type of a new
|
|
19
|
+
// instance. The proposal gets its `fact_status` (`written`, `in_review`, `refused`) and
|
|
20
|
+
// `review_note`, owned by this workflow.
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import {
|
|
23
|
+
entityRef,
|
|
24
|
+
triggeredWorkflow,
|
|
25
|
+
type LbbActivities,
|
|
26
|
+
type Schemas,
|
|
27
|
+
type WorkflowTriggerMessage,
|
|
28
|
+
} from "@littlebigbrain/client";
|
|
29
|
+
import * as review from "./decision.js";
|
|
30
|
+
|
|
31
|
+
export interface Settings {
|
|
32
|
+
/** The class of the proposals. */
|
|
33
|
+
className: string;
|
|
34
|
+
/** A proposal below this confidence goes to a person. */
|
|
35
|
+
gate: number;
|
|
36
|
+
/** What goes to a person whatever its confidence: `new_instance` (a statement about an instance the graph does not have yet) and predicate IRIs. Empty: the gate decides everything. */
|
|
37
|
+
alwaysReview: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The settings of LBB's review: edit them for your graph. */
|
|
41
|
+
export const defaults: Settings = {
|
|
42
|
+
className: "FactProposal",
|
|
43
|
+
gate: 0.8,
|
|
44
|
+
alwaysReview: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The workflow type and version the triggered workflow names. */
|
|
48
|
+
export const workflowType = { workflow_type: "review", version: "v1" };
|
|
49
|
+
|
|
50
|
+
/** The triggered workflow that sends the proposals here: `client.workflows.starters.use(trigger())`. */
|
|
51
|
+
export function trigger(settings: Settings = defaults): Schemas["TriggeredWorkflowRequest"] {
|
|
52
|
+
return {
|
|
53
|
+
starter: "workflow",
|
|
54
|
+
name: "review",
|
|
55
|
+
watch: { class: settings.className, fields: ["fact_subject", "fact_predicate", "fact_object"] },
|
|
56
|
+
workflow: workflowType,
|
|
57
|
+
owns: { properties: ["fact_status", "review_note"] },
|
|
58
|
+
batch: 32,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The class of the proposals and the properties of a proposal and of the review: `lbb.evolve(ontology())`. */
|
|
63
|
+
export function ontology(settings: Settings = defaults): Schemas["OntologyEvolveOp"][] {
|
|
64
|
+
return [
|
|
65
|
+
{ op: "add_entity_type", name: settings.className },
|
|
66
|
+
...Object.entries({ ...review.PROPOSAL_FIELDS, ...review.REVIEW_FIELDS }).map(
|
|
67
|
+
([name, value_type]) => ({ op: "add_property" as const, name, value_type }),
|
|
68
|
+
),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the workflow decided, over all its turns. */
|
|
73
|
+
export interface ReviewState {
|
|
74
|
+
proposals: number;
|
|
75
|
+
written: number;
|
|
76
|
+
held: number;
|
|
77
|
+
in_review: number;
|
|
78
|
+
refused: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Proposals one turn decides: two steps each, and at most one review item each, in a turn of 64 steps. */
|
|
82
|
+
const TURN_PROPOSALS = 20;
|
|
83
|
+
const PLAIN_IRI = /^[^\s<>"{}|\\^`]+$/;
|
|
84
|
+
const values = (iris: Iterable<string>) =>
|
|
85
|
+
[...iris]
|
|
86
|
+
.filter((iri) => PLAIN_IRI.test(iri))
|
|
87
|
+
.map((iri) => `<${iri}>`)
|
|
88
|
+
.join(" ");
|
|
89
|
+
const literals = (texts: Iterable<string>) => [...texts].map((t) => JSON.stringify(t)).join(" ");
|
|
90
|
+
const P = "PREFIX p: <https://littlebigbrain.com/p/>\n";
|
|
91
|
+
|
|
92
|
+
/** One proposal as the graph holds it. */
|
|
93
|
+
interface Proposal {
|
|
94
|
+
iri: string;
|
|
95
|
+
label?: string;
|
|
96
|
+
statement?: review.Statement;
|
|
97
|
+
confidence: number;
|
|
98
|
+
evidence?: string;
|
|
99
|
+
proposedBy: string;
|
|
100
|
+
status?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What one proposal's step decided, and the facts that write it. */
|
|
104
|
+
interface Outcome {
|
|
105
|
+
status: "written" | "in_review" | "refused" | "decided";
|
|
106
|
+
note?: string;
|
|
107
|
+
facts?: review.Facts;
|
|
108
|
+
holds?: boolean;
|
|
109
|
+
/** For a person: the item's instance, its view, and what the item shows of the fact. */
|
|
110
|
+
item?: {
|
|
111
|
+
instance: string;
|
|
112
|
+
class?: string;
|
|
113
|
+
label?: string;
|
|
114
|
+
isNew: boolean;
|
|
115
|
+
shown: review.Shown;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The workflow, for a `WorkflowWorker`. `lbb` is `lbbActivities(client)`, or a mock in a test. */
|
|
120
|
+
export function createWorkflow(lbb: LbbActivities, settings: Settings = defaults) {
|
|
121
|
+
return triggeredWorkflow<ReviewState>({
|
|
122
|
+
name: workflowType.workflow_type,
|
|
123
|
+
version: workflowType.version,
|
|
124
|
+
initialState: { proposals: 0, written: 0, held: 0, in_review: 0, refused: 0 },
|
|
125
|
+
async onInstances(ctx, message, state) {
|
|
126
|
+
const batch = message.instances.slice(0, TURN_PROPOSALS);
|
|
127
|
+
let cache: Promise<Read> | undefined;
|
|
128
|
+
const read = () => (cache ??= reader(lbb, batch, settings));
|
|
129
|
+
const next = { ...state };
|
|
130
|
+
const forPeople: { iri: string; outcome: Outcome }[] = [];
|
|
131
|
+
for (const [i, iri] of batch.entries()) {
|
|
132
|
+
const outcome = await ctx.step(`decide-${i}`, async (s) => {
|
|
133
|
+
s.progress("deciding proposals", { done: i, total: batch.length, unit: "proposals" });
|
|
134
|
+
return decideOne(lbb, await read(), iri, settings);
|
|
135
|
+
});
|
|
136
|
+
await lbb.writeOwned(ctx, `write-${i}`, async (s) => {
|
|
137
|
+
s.progress("writing facts", { done: i, total: batch.length, unit: "proposals" });
|
|
138
|
+
return statusWrite(await read(), iri, outcome, settings);
|
|
139
|
+
});
|
|
140
|
+
if (outcome.status === "decided") continue;
|
|
141
|
+
next.proposals += 1;
|
|
142
|
+
if (outcome.status === "written") next[outcome.holds ? "held" : "written"] += 1;
|
|
143
|
+
else next[outcome.status] += 1;
|
|
144
|
+
if (outcome.status === "in_review") forPeople.push({ iri, outcome });
|
|
145
|
+
}
|
|
146
|
+
// One review item per instance of this turn.
|
|
147
|
+
const groups = new Map<string, { iri: string; outcome: Outcome }[]>();
|
|
148
|
+
for (const one of forPeople) {
|
|
149
|
+
const instance = one.outcome.item!.instance;
|
|
150
|
+
groups.set(instance, [...(groups.get(instance) ?? []), one]);
|
|
151
|
+
}
|
|
152
|
+
for (const [n, members] of [...groups.values()].entries())
|
|
153
|
+
await ctx.step(`review-${n}`, async (s) => {
|
|
154
|
+
s.progress("filing review items", { done: n, total: groups.size, unit: "instances" });
|
|
155
|
+
await fileItem(lbb, await read(), members, message, settings);
|
|
156
|
+
return true;
|
|
157
|
+
});
|
|
158
|
+
// A proposal its proposer withdrew (message.removed) keeps the fact written for it: see the README.
|
|
159
|
+
const rest = message.instances.slice(TURN_PROPOSALS);
|
|
160
|
+
return rest.length
|
|
161
|
+
? ctx.continue(next, { ...message, instances: rest, removed: [] })
|
|
162
|
+
: { state: next };
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type Read = Awaited<ReturnType<typeof reader>>;
|
|
168
|
+
|
|
169
|
+
/** The proposals of the turn and the ontology, read once per attempt, with the commits not yet published. */
|
|
170
|
+
async function reader(lbb: LbbActivities, iris: string[], settings: Settings) {
|
|
171
|
+
const [{ rows }, view] = [
|
|
172
|
+
await lbb.sparql(
|
|
173
|
+
`# review: proposals\n${P}SELECT ?x ?label ?s ?p ?o ?dt ?c ?ev ?by ?status WHERE {
|
|
174
|
+
VALUES ?x { ${values(iris)} }
|
|
175
|
+
OPTIONAL { ?x <${review.RDFS_LABEL}> ?label }
|
|
176
|
+
OPTIONAL { ?x p:fact_subject ?s } OPTIONAL { ?x p:fact_predicate ?p } OPTIONAL { ?x p:fact_object ?o }
|
|
177
|
+
OPTIONAL { ?x p:fact_datatype ?dt } OPTIONAL { ?x p:fact_confidence ?c } OPTIONAL { ?x p:fact_evidence ?ev }
|
|
178
|
+
OPTIONAL { ?x p:proposed_by ?by } OPTIONAL { ?x p:fact_status ?status }
|
|
179
|
+
}`,
|
|
180
|
+
{ consistency: "strong" },
|
|
181
|
+
),
|
|
182
|
+
await lbb.ontology(),
|
|
183
|
+
];
|
|
184
|
+
const proposals = new Map<string, Proposal>();
|
|
185
|
+
for (const r of rows) {
|
|
186
|
+
if (proposals.has(r.x!)) continue;
|
|
187
|
+
proposals.set(r.x!, {
|
|
188
|
+
iri: r.x!,
|
|
189
|
+
...(r.label ? { label: r.label } : {}),
|
|
190
|
+
...(r.s && r.p && r.o !== undefined
|
|
191
|
+
? {
|
|
192
|
+
statement: {
|
|
193
|
+
subject: r.s.trim(),
|
|
194
|
+
predicate: r.p.trim(),
|
|
195
|
+
object: r.o,
|
|
196
|
+
...(r.dt?.trim() ? { datatype: r.dt.trim() } : {}),
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
200
|
+
confidence: Number(r.c ?? 0),
|
|
201
|
+
...(r.ev ? { evidence: r.ev } : {}),
|
|
202
|
+
proposedBy: r.by ?? "",
|
|
203
|
+
...(r.status ? { status: r.status } : {}),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return { proposals, ontology: review.readOntology(view), className: settings.className };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The entities a statement names: the graph's instances, and new ones with the class and label their proposals give. */
|
|
210
|
+
async function entities(
|
|
211
|
+
lbb: LbbActivities,
|
|
212
|
+
read: Read,
|
|
213
|
+
iris: string[],
|
|
214
|
+
): Promise<Map<string, review.Known>> {
|
|
215
|
+
const out = new Map<string, review.Known>();
|
|
216
|
+
const named = iris.filter(review.isEntity);
|
|
217
|
+
if (!named.length) return out;
|
|
218
|
+
const { rows } = await lbb.sparql(
|
|
219
|
+
`# review: entities\nSELECT ?s ?type ?label WHERE {
|
|
220
|
+
VALUES ?s { ${values(named)} }
|
|
221
|
+
OPTIONAL { ?s a ?type } OPTIONAL { ?s <${review.RDFS_LABEL}> ?label }
|
|
222
|
+
}`,
|
|
223
|
+
{ consistency: "strong" },
|
|
224
|
+
);
|
|
225
|
+
for (const iri of named) {
|
|
226
|
+
const found = rows.filter((r) => r.s === iri);
|
|
227
|
+
const cls = found.map((r) => read.ontology.classes.get(r.type ?? "")).find(Boolean);
|
|
228
|
+
if (cls)
|
|
229
|
+
out.set(iri, { iri, class: cls, label: found.find((r) => r.label)?.label, exists: true });
|
|
230
|
+
}
|
|
231
|
+
// A new instance: the class of its rdf:type proposal, and the name its rdfs:label proposals
|
|
232
|
+
// give it (the first in order), so every write names it alike.
|
|
233
|
+
const fresh = named.filter((iri) => !out.has(iri));
|
|
234
|
+
if (fresh.length) {
|
|
235
|
+
const { rows: proposed } = await lbb.sparql(
|
|
236
|
+
`# review: new instances\n${P}SELECT ?s ?p ?o WHERE {
|
|
237
|
+
VALUES ?s { ${literals(fresh)} }
|
|
238
|
+
VALUES ?p { ${literals([review.RDF_TYPE, review.RDFS_LABEL])} }
|
|
239
|
+
?x p:fact_subject ?s ; p:fact_predicate ?p ; p:fact_object ?o .
|
|
240
|
+
}`,
|
|
241
|
+
{ consistency: "strong" },
|
|
242
|
+
);
|
|
243
|
+
for (const iri of fresh) {
|
|
244
|
+
const of = (p: string) =>
|
|
245
|
+
proposed
|
|
246
|
+
.filter((r) => r.s === iri && r.p === p)
|
|
247
|
+
.map((r) => r.o!.trim())
|
|
248
|
+
.filter(Boolean)
|
|
249
|
+
.sort();
|
|
250
|
+
const cls = of(review.RDF_TYPE)
|
|
251
|
+
.map((c) => read.ontology.classes.get(c))
|
|
252
|
+
.find(Boolean);
|
|
253
|
+
out.set(iri, {
|
|
254
|
+
iri,
|
|
255
|
+
...(cls ? { class: cls } : {}),
|
|
256
|
+
label: of(review.RDFS_LABEL)[0],
|
|
257
|
+
exists: false,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Decide one proposal; one for a person carries what its item shows. */
|
|
265
|
+
async function decideOne(
|
|
266
|
+
lbb: LbbActivities,
|
|
267
|
+
read: Read,
|
|
268
|
+
iri: string,
|
|
269
|
+
settings: Settings,
|
|
270
|
+
): Promise<Outcome> {
|
|
271
|
+
const proposal = read.proposals.get(iri);
|
|
272
|
+
// Gone, or decided once already: a proposer's rewrite of the same statement changes nothing.
|
|
273
|
+
if (!proposal || proposal.status) return { status: "decided" };
|
|
274
|
+
const s = proposal.statement;
|
|
275
|
+
if (!s)
|
|
276
|
+
return {
|
|
277
|
+
status: "refused",
|
|
278
|
+
note: "The proposal has no fact_subject, fact_predicate or fact_object.",
|
|
279
|
+
};
|
|
280
|
+
const seen: review.Seen = { entities: await entities(lbb, read, [s.subject, s.object]) };
|
|
281
|
+
const property = read.ontology.properties.get(s.predicate);
|
|
282
|
+
if (property && review.isEntity(s.subject)) {
|
|
283
|
+
const { rows } = await lbb.sparql(
|
|
284
|
+
`# review: values\nSELECT ?v WHERE { <${s.subject}> <${s.predicate}> ?v } LIMIT 16`,
|
|
285
|
+
{ consistency: "strong" },
|
|
286
|
+
);
|
|
287
|
+
seen.current = rows.map((r) => r.v!);
|
|
288
|
+
}
|
|
289
|
+
const terms = review.classify(s, read.ontology, seen, settings.className);
|
|
290
|
+
let edgeHolds = false;
|
|
291
|
+
if (terms.kind === "relation" && terms.subject.exists && terms.object.exists) {
|
|
292
|
+
const { rows } = await lbb.sparql(
|
|
293
|
+
`# review: edge\nSELECT ?s WHERE { VALUES ?s { <${s.subject}> } ?s <${s.predicate}> <${s.object}> } LIMIT 1`,
|
|
294
|
+
{ consistency: "strong" },
|
|
295
|
+
);
|
|
296
|
+
edgeHolds = rows.length > 0;
|
|
297
|
+
}
|
|
298
|
+
const decision = review.decide(terms, s.predicate, proposal.confidence, settings, edgeHolds);
|
|
299
|
+
if (decision.status === "refused" || terms.kind === "refused")
|
|
300
|
+
return { status: "refused", note: decision.status === "refused" ? decision.why : "" };
|
|
301
|
+
const facts = review.factsOf(terms, iri, proposal.confidence, proposal.evidence);
|
|
302
|
+
if (decision.status === "written")
|
|
303
|
+
return decision.holds
|
|
304
|
+
? { status: "written", holds: true }
|
|
305
|
+
: { status: "written", holds: false, facts };
|
|
306
|
+
// To a person: into the item of its instance.
|
|
307
|
+
const instance = review.groupOf(terms, s)!;
|
|
308
|
+
const known = [...seen.entities.values()].find((e) => e.iri === instance) ?? {
|
|
309
|
+
iri: instance,
|
|
310
|
+
exists: false,
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
status: "in_review",
|
|
314
|
+
note: decision.why,
|
|
315
|
+
facts,
|
|
316
|
+
item: {
|
|
317
|
+
instance,
|
|
318
|
+
...(known.class || terms.kind === "type"
|
|
319
|
+
? { class: terms.kind === "type" ? terms.class : known.class }
|
|
320
|
+
: {}),
|
|
321
|
+
...(known.label ? { label: known.label } : {}),
|
|
322
|
+
isNew: !known.exists,
|
|
323
|
+
shown: review.shownOf(terms, s, instance),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const proposalRef = (proposal: Proposal, settings: Settings) =>
|
|
329
|
+
entityRef(proposal.iri, settings.className, proposal.label);
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* File the review item of one instance: each fact's statement and its proposal's
|
|
333
|
+
* `fact_status: accepted`, in the item's order (the type, the label, values, links, the trace),
|
|
334
|
+
* with one evidence sample per fact. The key carries the proposals, so other proposals file
|
|
335
|
+
* another item.
|
|
336
|
+
*/
|
|
337
|
+
async function fileItem(
|
|
338
|
+
lbb: LbbActivities,
|
|
339
|
+
read: Read,
|
|
340
|
+
members: { iri: string; outcome: Outcome }[],
|
|
341
|
+
message: WorkflowTriggerMessage,
|
|
342
|
+
settings: Settings,
|
|
343
|
+
) {
|
|
344
|
+
const first = members[0]!.outcome.item!;
|
|
345
|
+
const instance = first.instance;
|
|
346
|
+
const kind = (m: { outcome: Outcome }) => m.outcome.item!.shown.kind;
|
|
347
|
+
const ordered = [...members].sort(
|
|
348
|
+
(a, b) =>
|
|
349
|
+
review.KIND_ORDER.indexOf(kind(a)) - review.KIND_ORDER.indexOf(kind(b)) ||
|
|
350
|
+
a.outcome.item!.shown.text.localeCompare(b.outcome.item!.shown.text),
|
|
351
|
+
);
|
|
352
|
+
const creates = first.isNew && ordered.some((m) => kind(m) === "type");
|
|
353
|
+
const className = ordered.map((m) => m.outcome.item!.class).find(Boolean) ?? "Instance";
|
|
354
|
+
const label =
|
|
355
|
+
ordered.map((m) => m.outcome.item!.label).find(Boolean) ??
|
|
356
|
+
instance.slice(instance.lastIndexOf("/") + 1);
|
|
357
|
+
const triplets: Schemas["TripletInput"][] = [];
|
|
358
|
+
const rows: Schemas["EntityPropertiesInput"][] = [];
|
|
359
|
+
// The rows an accept must keep (`required`, from client 0.24; an older client sends it as is).
|
|
360
|
+
const required: { row: number; reason: string }[] = [];
|
|
361
|
+
const samples: Record<string, unknown>[] = [];
|
|
362
|
+
const whys = new Set<string>();
|
|
363
|
+
for (const { iri, outcome } of ordered) {
|
|
364
|
+
const proposal = read.proposals.get(iri)!;
|
|
365
|
+
const shown = outcome.item!.shown;
|
|
366
|
+
const needed = shown.kind === "source" || (creates && shown.kind === "type");
|
|
367
|
+
const writeTriplets = (outcome.facts?.triplets ?? []).map((t) => triplets.push(t) - 1);
|
|
368
|
+
const writeRows = (outcome.facts?.entity_properties ?? []).map((r) => rows.push(r) - 1);
|
|
369
|
+
const row =
|
|
370
|
+
rows.push({ ...proposalRef(proposal, settings), properties: { fact_status: "accepted" } }) -
|
|
371
|
+
1;
|
|
372
|
+
if (needed)
|
|
373
|
+
for (const at of [...writeRows, row])
|
|
374
|
+
required.push({
|
|
375
|
+
row: at,
|
|
376
|
+
reason:
|
|
377
|
+
shown.kind === "type"
|
|
378
|
+
? "a new instance needs its type"
|
|
379
|
+
: "the source of an instance goes with it",
|
|
380
|
+
});
|
|
381
|
+
if (outcome.note) whys.add(outcome.note);
|
|
382
|
+
samples.push({
|
|
383
|
+
proposal: iri,
|
|
384
|
+
row,
|
|
385
|
+
...(writeRows.length ? { rows: writeRows } : {}),
|
|
386
|
+
...(writeTriplets.length ? { triplets: writeTriplets } : {}),
|
|
387
|
+
kind: shown.kind,
|
|
388
|
+
fact: shown.text,
|
|
389
|
+
confidence: Math.round(proposal.confidence * 1000) / 1000,
|
|
390
|
+
...(review.place(proposal.evidence) ?? {}),
|
|
391
|
+
...(needed ? { required: true } : {}),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const proposalsHash = createHash("sha256")
|
|
395
|
+
.update(
|
|
396
|
+
ordered
|
|
397
|
+
.map((m) => m.iri)
|
|
398
|
+
.sort()
|
|
399
|
+
.join("\n"),
|
|
400
|
+
)
|
|
401
|
+
.digest("hex")
|
|
402
|
+
.slice(0, 12);
|
|
403
|
+
const title = creates
|
|
404
|
+
? `New ${className} ${label}`
|
|
405
|
+
: `${className} ${label}: ${members.length === 1 ? "1 fact" : `${members.length} facts`} to check`;
|
|
406
|
+
const proposers = [...new Set(ordered.map((m) => read.proposals.get(m.iri)!.proposedBy))].filter(
|
|
407
|
+
Boolean,
|
|
408
|
+
);
|
|
409
|
+
await lbb.review({
|
|
410
|
+
workflow: message.workflow,
|
|
411
|
+
key: `starter:${message.workflow}:instance:${instance.slice(instance.lastIndexOf("/") + 1)}:${proposalsHash}`,
|
|
412
|
+
title: review.clip(title, 200),
|
|
413
|
+
rationale: review.clip(
|
|
414
|
+
`${[...whys].slice(0, 3).join(" ")}${proposers.length ? ` Proposed by ${proposers.join(", ")}.` : ""}`,
|
|
415
|
+
500,
|
|
416
|
+
),
|
|
417
|
+
facts: {
|
|
418
|
+
triplets,
|
|
419
|
+
entity_properties: rows,
|
|
420
|
+
required,
|
|
421
|
+
summary: review.clip(ordered.map((m) => m.outcome.item!.shown.text).join(" · "), 500),
|
|
422
|
+
} as Schemas["SuggestedFacts"],
|
|
423
|
+
mapping: {
|
|
424
|
+
review: {
|
|
425
|
+
instance,
|
|
426
|
+
class: className,
|
|
427
|
+
label,
|
|
428
|
+
new: creates,
|
|
429
|
+
facts: members.length,
|
|
430
|
+
shown: members.length,
|
|
431
|
+
truncated: false,
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
evidence: { records: members.length, samples },
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The owned write of a decision: the proposal's status and note, and the facts of a written statement. */
|
|
439
|
+
function statusWrite(read: Read, iri: string, outcome: Outcome, settings: Settings) {
|
|
440
|
+
const proposal = read.proposals.get(iri);
|
|
441
|
+
if (!proposal || outcome.status === "decided") return { subjects: [] };
|
|
442
|
+
const own = proposalRef(proposal, settings);
|
|
443
|
+
const written = outcome.status === "written" ? outcome.facts : undefined;
|
|
444
|
+
return {
|
|
445
|
+
subjects: [own],
|
|
446
|
+
properties: ["fact_status", "review_note"],
|
|
447
|
+
entity_properties: [
|
|
448
|
+
...(written?.entity_properties ?? []),
|
|
449
|
+
{
|
|
450
|
+
...own,
|
|
451
|
+
properties: {
|
|
452
|
+
fact_status: outcome.status,
|
|
453
|
+
...(outcome.note ? { review_note: review.clip(outcome.note, 500) } : {}),
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
triplets: written?.triplets ?? [],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# values-extract
|
|
2
|
+
|
|
3
|
+
Fills properties of a class from the text of its instances. Each value
|
|
4
|
+
comes with the words of the text that state it: `width_cm` 60 and
|
|
5
|
+
`depth_cm` 40 from `ca. 60x40 cm`. The value is written on the instance,
|
|
6
|
+
with an edge `HAS_VALUE_SOURCE` to a `ValueSource` entity whose evidence is
|
|
7
|
+
the quote.
|
|
8
|
+
|
|
9
|
+
The LBB-run version is the starter workflow `values.extract@v1`
|
|
10
|
+
(`docs/reference/starter-workflows.md`, "Values from text").
|
|
11
|
+
|
|
12
|
+
## How it runs
|
|
13
|
+
|
|
14
|
+
The triggered workflow watches the class and its text field. For each
|
|
15
|
+
instance of a trigger message, two steps:
|
|
16
|
+
|
|
17
|
+
1. **Values.** The reasoning model reads the text and returns
|
|
18
|
+
`{values: [{property, value, quote}]}`; the schema allows only the
|
|
19
|
+
settings' property names. The prompt is the LBB version's: the values of
|
|
20
|
+
the instance's own item in its base version, not those of a part, an
|
|
21
|
+
accessory, an alternative or a variant under a condition; an approximate
|
|
22
|
+
value is stated. Then the checks without a model: a value takes its
|
|
23
|
+
property's type (`typed`), its quote is in the text with at least three
|
|
24
|
+
words or the value in it (`quoteHolds`), and one value per property.
|
|
25
|
+
2. **Support.** The decision model scores how well each quote supports its
|
|
26
|
+
value: `supported`, `weak` or `not_supported`. The support is the
|
|
27
|
+
probability of `supported`.
|
|
28
|
+
|
|
29
|
+
Then the gate: a value at or above `gate` is written, one from 0.5 to the
|
|
30
|
+
gate becomes a review item with the value as its facts, one below 0.5 is
|
|
31
|
+
dropped. One owned write per turn replaces the values of the turn's
|
|
32
|
+
instances: a property the workflow does not write again loses its value and
|
|
33
|
+
its source. An accepted review item's value is written with the others the
|
|
34
|
+
next time its instance is read. An instance that leaves the class loses the
|
|
35
|
+
values this workflow wrote.
|
|
36
|
+
|
|
37
|
+
A turn reads at most 24 instances; the rest of a message goes to a new turn
|
|
38
|
+
(`ctx.continue`).
|
|
39
|
+
|
|
40
|
+
## Settings
|
|
41
|
+
|
|
42
|
+
`defaults` in `workflow.ts`, which fit the samples:
|
|
43
|
+
|
|
44
|
+
| Setting | Default | Meaning |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `className` | `EquipmentItem` | The class whose instances are read |
|
|
47
|
+
| `textField` | `text` | The property that holds the text |
|
|
48
|
+
| `properties` | `width_cm`, `depth_cm` (`f64`), `material` (`text`), `water_supply` (`keyword`) | The properties to fill, with their types (`text`, `keyword`, `i64`, `f64`, `bool`, `date_time`) |
|
|
49
|
+
| `context` | the samples' | One sentence about what the text is |
|
|
50
|
+
| `gate` | 0.8 | The support a value needs to be written |
|
|
51
|
+
|
|
52
|
+
`trigger(settings)` names the triggered workflow `values-extract` and
|
|
53
|
+
owns the class `ValueSource`, the relation `HAS_VALUE_SOURCE` and the
|
|
54
|
+
properties. `ontology(settings)` adds the class, the properties and the
|
|
55
|
+
relation.
|
|
56
|
+
|
|
57
|
+
## What the LBB-run version adds
|
|
58
|
+
|
|
59
|
+
- `params.where`: a SPARQL pattern that limits the instances it reads.
|
|
60
|
+
- Texts built from several watched fields, as fit sources build them, and
|
|
61
|
+
the fields chosen for you when the watch names none.
|
|
62
|
+
- The instance's page box as the region of each value's evidence, for an
|
|
63
|
+
instance that has one (a `DocEntry`).
|
|
64
|
+
- A check at creation that each property is in the property registry with
|
|
65
|
+
a type it can write.
|
|
66
|
+
- Its own monthly budget, a preview with the values of up to 3 instances,
|
|
67
|
+
and counters per outcome in the workflow's status.
|
|
68
|
+
|
|
69
|
+
## Samples and test
|
|
70
|
+
|
|
71
|
+
`samples/records.json` holds six invented entries of a sanitary equipment
|
|
72
|
+
list; `samples/expected.json` gives the values a correct run writes, and the
|
|
73
|
+
ones it leaves out ([`../_samples/README.md`](../_samples/README.md)).
|
|
74
|
+
|
|
75
|
+
`workflow.test.ts` runs the workflow with mock models: the reasoning model
|
|
76
|
+
reads the first size, material and water supply of each text, and the
|
|
77
|
+
decision model says a quote from a sentence about an accessory (`Zubehör`)
|
|
78
|
+
or a conditional variant (`Bei Einsatz`) does not support its value. The run
|
|
79
|
+
writes the values of the answer key and none it leaves out. The test also
|
|
80
|
+
checks the review path, the checks of type and quote, an instance that
|
|
81
|
+
leaves the class, and a message larger than a turn.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": [
|
|
3
|
+
"The answer key for the values-extract samples: the values a correct run reads from each record, with the quote that supports each, and the values it must leave out.",
|
|
4
|
+
"All text is invented. make-samples.mjs checks that every quote is in its record's text (whitespace runs as one space) and that a number's quote holds the number.",
|
|
5
|
+
"Numbers compare as numbers. A text value compares in lower case, trimmed, against accept. A run may quote more or less of the text, as long as its quote is in the text and holds the evidence.",
|
|
6
|
+
"leave_out lists values a correct run does not write. With value null, the run writes no value for that property at all."
|
|
7
|
+
],
|
|
8
|
+
"install": {
|
|
9
|
+
"properties": {
|
|
10
|
+
"width_cm": "f64",
|
|
11
|
+
"depth_cm": "f64",
|
|
12
|
+
"material": "text",
|
|
13
|
+
"water_supply": "keyword"
|
|
14
|
+
},
|
|
15
|
+
"context": "Entries of the sanitary equipment list of a school building tender, in German."
|
|
16
|
+
},
|
|
17
|
+
"records": [
|
|
18
|
+
{
|
|
19
|
+
"name": "S 4.1",
|
|
20
|
+
"case": "An approximate size (ca.) is still the item's size.",
|
|
21
|
+
"values": [
|
|
22
|
+
{ "property": "width_cm", "value": 60, "quote": "ca. 60x40 cm" },
|
|
23
|
+
{ "property": "depth_cm", "value": 40, "quote": "ca. 60x40 cm" },
|
|
24
|
+
{
|
|
25
|
+
"property": "material",
|
|
26
|
+
"value": "Sanitärkeramik",
|
|
27
|
+
"accept": ["sanitärkeramik", "keramik", "sanitärkeramik, weiß", "sanitärkeramik weiß"],
|
|
28
|
+
"quote": "aus Sanitärkeramik, weiß"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"property": "water_supply",
|
|
32
|
+
"value": "kalt und warm",
|
|
33
|
+
"accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
|
|
34
|
+
"quote": "Anschluss an Kalt- und Warmwasser"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"leave_out": []
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "S 4.2",
|
|
41
|
+
"case": "An accessory of another material: the Klapprost.",
|
|
42
|
+
"values": [
|
|
43
|
+
{ "property": "width_cm", "value": 50, "quote": "Stahl emailliert, 50x35 cm" },
|
|
44
|
+
{ "property": "depth_cm", "value": 35, "quote": "Stahl emailliert, 50x35 cm" },
|
|
45
|
+
{
|
|
46
|
+
"property": "material",
|
|
47
|
+
"value": "Stahl emailliert",
|
|
48
|
+
"accept": ["stahl emailliert", "stahl, emailliert", "emaillierter stahl"],
|
|
49
|
+
"quote": "Ausgussbecken für Putzräume, Stahl emailliert"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"property": "water_supply",
|
|
53
|
+
"value": "kalt",
|
|
54
|
+
"accept": ["kalt", "kaltwasser", "nur kaltwasser", "nur kalt"],
|
|
55
|
+
"quote": "Anschluss nur an Kaltwasser"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"leave_out": [
|
|
59
|
+
{ "property": "material", "value": "Edelstahl", "reason": "Edelstahl is the material of the Klapprost, an accessory." }
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "S 4.3",
|
|
64
|
+
"case": "The only size in the text belongs to an accessory, the Glasablage.",
|
|
65
|
+
"values": [
|
|
66
|
+
{
|
|
67
|
+
"property": "material",
|
|
68
|
+
"value": "Mineralguss",
|
|
69
|
+
"accept": ["mineralguss"],
|
|
70
|
+
"quote": "Waschtisch, unterfahrbar, aus Mineralguss"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"property": "water_supply",
|
|
74
|
+
"value": "kalt und warm",
|
|
75
|
+
"accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
|
|
76
|
+
"quote": "Anschluss an Kalt- und Warmwasser"
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"leave_out": [
|
|
80
|
+
{ "property": "width_cm", "value": null, "reason": "50x12 cm is the size of the Glasablage. The basin's size is left to the maker (Maße nach Hersteller)." },
|
|
81
|
+
{ "property": "depth_cm", "value": null, "reason": "50x12 cm is the size of the Glasablage. The basin's size is left to the maker (Maße nach Hersteller)." },
|
|
82
|
+
{ "property": "material", "value": "Glas", "reason": "Glas is the material of the Glasablage, an accessory." },
|
|
83
|
+
{ "property": "material", "value": "Edelstahl", "reason": "Edelstahl is the material of the Haltegriff, an accessory." }
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "S 4.4",
|
|
88
|
+
"case": "Every value stated once, plainly.",
|
|
89
|
+
"values": [
|
|
90
|
+
{ "property": "width_cm", "value": 40, "quote": "Edelstahl, 40x30 cm" },
|
|
91
|
+
{ "property": "depth_cm", "value": 30, "quote": "Edelstahl, 40x30 cm" },
|
|
92
|
+
{
|
|
93
|
+
"property": "material",
|
|
94
|
+
"value": "Edelstahl",
|
|
95
|
+
"accept": ["edelstahl"],
|
|
96
|
+
"quote": "Trinkbrunnen für den Flur, Edelstahl"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"property": "water_supply",
|
|
100
|
+
"value": "kalt",
|
|
101
|
+
"accept": ["kalt", "kaltwasser", "nur kaltwasser", "nur kalt"],
|
|
102
|
+
"quote": "Anschluss an Kaltwasser in Trinkwasserqualität"
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"leave_out": []
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "S 4.5",
|
|
109
|
+
"case": "A variant that holds only under a condition (Bei Einsatz von Geschirrspülern).",
|
|
110
|
+
"values": [
|
|
111
|
+
{ "property": "width_cm", "value": 100, "quote": "Edelstahl, 100x50 cm" },
|
|
112
|
+
{ "property": "depth_cm", "value": 50, "quote": "Edelstahl, 100x50 cm" },
|
|
113
|
+
{
|
|
114
|
+
"property": "material",
|
|
115
|
+
"value": "Edelstahl",
|
|
116
|
+
"accept": ["edelstahl"],
|
|
117
|
+
"quote": "Spüle für die Lehrerküche, Edelstahl"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"property": "water_supply",
|
|
121
|
+
"value": "kalt und warm",
|
|
122
|
+
"accept": ["kalt und warm", "warm und kalt", "kalt- und warmwasser", "kaltwasser und warmwasser"],
|
|
123
|
+
"quote": "Anschluss an Kalt- und Warmwasser"
|
|
124
|
+
}
|
|
125
|
+
],
|
|
126
|
+
"leave_out": [
|
|
127
|
+
{ "property": "width_cm", "value": 120, "reason": "The width of the variant with two basins, which holds only bei Einsatz von Geschirrspülern." }
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "S 4.6",
|
|
132
|
+
"case": "A second size that is a height, and no water supply.",
|
|
133
|
+
"values": [
|
|
134
|
+
{ "property": "width_cm", "value": 60, "quote": "Breite 60 cm" },
|
|
135
|
+
{
|
|
136
|
+
"property": "material",
|
|
137
|
+
"value": "Kristallglas",
|
|
138
|
+
"accept": ["kristallglas", "glas"],
|
|
139
|
+
"quote": "Spiegel für Waschplätze, Kristallglas"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"leave_out": [
|
|
143
|
+
{ "property": "depth_cm", "value": null, "reason": "45 cm is the height (Höhe). The text gives no depth." },
|
|
144
|
+
{ "property": "water_supply", "value": null, "reason": "The text names no water supply." }
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|