@littlebigbrain/create 0.4.0 → 0.6.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 +42 -6
- package/lib/create.mjs +228 -14
- package/package.json +2 -1
- package/project/python/values_extract/tests/fake_lbb.py +226 -0
- package/project/python/values_extract/tests/test_workflow.py +150 -0
- package/python/values_extract/README.md +52 -0
- package/python/values_extract/__init__.py +3 -0
- package/python/values_extract/__main__.py +97 -0
- package/python/values_extract/samples/expected.json +149 -0
- package/python/values_extract/samples/records.json +26 -0
- package/python/values_extract/samples.py +37 -0
- package/python/values_extract/workflow.py +1044 -0
- package/python/versions.json +3 -0
- package/templates/_shared/testing.ts +259 -5
- package/templates/ontology-fit/README.md +41 -32
- package/templates/ontology-fit/workflow.test.ts +56 -5
- package/templates/ontology-fit/workflow.ts +708 -207
- package/templates/review/README.md +56 -29
- package/templates/review/decision.ts +118 -37
- package/templates/review/workflow.test.ts +30 -10
- package/templates/review/workflow.ts +123 -76
- package/templates/rows/README.md +8 -1
- package/templates/rows/workflow.test.ts +56 -0
- package/templates/rows/workflow.ts +182 -148
- package/templates/sections/README.md +8 -1
- package/templates/sections/workflow.test.ts +51 -1
- package/templates/sections/workflow.ts +137 -98
- package/templates/values-extract/README.md +92 -23
- package/templates/values-extract/samples/expected.json +1 -0
- package/templates/values-extract/test-examples.ts +155 -0
- package/templates/values-extract/workflow.test.ts +532 -27
- package/templates/values-extract/workflow.ts +719 -179
- package/templates/versions.json +1 -1
|
@@ -16,12 +16,15 @@ import {
|
|
|
16
16
|
type DecideResult,
|
|
17
17
|
type EmbedRequest,
|
|
18
18
|
type EmbedResult,
|
|
19
|
+
type FailedInstance,
|
|
19
20
|
type FilePages,
|
|
20
21
|
type LbbActivities,
|
|
21
22
|
type OwnedWrite,
|
|
22
23
|
type ReasonRequest,
|
|
23
24
|
type ReasonResult,
|
|
25
|
+
type ReviewItemRequest,
|
|
24
26
|
type ReviewRequest,
|
|
27
|
+
type ReviewWaitResult,
|
|
25
28
|
type Schemas,
|
|
26
29
|
type SparqlResults,
|
|
27
30
|
type WorkflowContext,
|
|
@@ -146,6 +149,10 @@ export interface MockRules {
|
|
|
146
149
|
refuses?: (kind: "reason" | "decide" | "embed", request: unknown) => boolean;
|
|
147
150
|
/** The status a review item gets (default `open`). */
|
|
148
151
|
reviewStatus?: (request: ReviewRequest) => Schemas["OntologyChangeSuggestionStatus"];
|
|
152
|
+
/** The answer a person gave to a review item of another kind than a change (default none: open). */
|
|
153
|
+
reviewAnswer?: (request: ReviewItemRequest) => Schemas["ReviewAnswer"] | undefined;
|
|
154
|
+
/** Why the graph refused the answer's value (`effects.commit_error`), if it did. */
|
|
155
|
+
reviewRefused?: (request: ReviewItemRequest) => string | undefined;
|
|
149
156
|
pages?: FilePages[];
|
|
150
157
|
ontology?: Schemas["OntologyView"];
|
|
151
158
|
/** What a dry-run evolve answers (default: a change that is not a no-op). */
|
|
@@ -170,7 +177,18 @@ export class MockActivities implements LbbActivities {
|
|
|
170
177
|
readonly graph = new MemoryGraph();
|
|
171
178
|
readonly calls: MockCall[] = [];
|
|
172
179
|
readonly reviews = new Map<string, ReviewRequest & { status: string }>();
|
|
180
|
+
/** The review items of other kinds than a change (`value`, `choice`, …), by key. */
|
|
181
|
+
readonly questions = new Map<string, ReviewItemRequest>();
|
|
182
|
+
/** Items asked again on new evidence: the answer in force they hold until a new one. */
|
|
183
|
+
readonly previous = new Map<string, Schemas["ReviewAnswer"]>();
|
|
173
184
|
readonly evolved: Schemas["OntologyEvolveOp"][][] = [];
|
|
185
|
+
/** The instances each turn sent back to the watch (`lbb.requeue`), by step effect id. */
|
|
186
|
+
readonly requeued: {
|
|
187
|
+
effectId: string;
|
|
188
|
+
workflowId: string;
|
|
189
|
+
turn: number;
|
|
190
|
+
failed: FailedInstance[];
|
|
191
|
+
}[] = [];
|
|
174
192
|
private readonly kept = new Map<string, { request: string; answer: unknown }>();
|
|
175
193
|
private readonly written = new Set<string>();
|
|
176
194
|
|
|
@@ -299,7 +317,23 @@ export class MockActivities implements LbbActivities {
|
|
|
299
317
|
const { effectId } = step;
|
|
300
318
|
const replayed = this.written.has(effectId);
|
|
301
319
|
if (!replayed) {
|
|
302
|
-
|
|
320
|
+
const body = typeof write === "function" ? await write(step) : write;
|
|
321
|
+
// As the SDK does: a write of nothing sends no commit.
|
|
322
|
+
const empty =
|
|
323
|
+
!body.subjects.length &&
|
|
324
|
+
!body.retract_entities?.length &&
|
|
325
|
+
!body.triplets?.length &&
|
|
326
|
+
!body.entity_properties?.length;
|
|
327
|
+
if (empty)
|
|
328
|
+
return {
|
|
329
|
+
commit_seq: null,
|
|
330
|
+
idempotency_key: effectId,
|
|
331
|
+
replayed: false,
|
|
332
|
+
owned: null,
|
|
333
|
+
entities_written: 0,
|
|
334
|
+
edges_written: 0,
|
|
335
|
+
};
|
|
336
|
+
this.graph.apply(body);
|
|
303
337
|
this.written.add(effectId);
|
|
304
338
|
}
|
|
305
339
|
return {
|
|
@@ -313,7 +347,136 @@ export class MockActivities implements LbbActivities {
|
|
|
313
347
|
});
|
|
314
348
|
}
|
|
315
349
|
|
|
316
|
-
|
|
350
|
+
/**
|
|
351
|
+
* `lbb.requeue` inside the step `stepKey`, as the SDK runs it: nothing
|
|
352
|
+
* without failed instances, and a repeat of the step reads its saved
|
|
353
|
+
* answer. The answer counts the attempt from 1 (the server takes it from
|
|
354
|
+
* the turn's message).
|
|
355
|
+
*/
|
|
356
|
+
async requeue(
|
|
357
|
+
ctx: WorkflowContext,
|
|
358
|
+
stepKey: string,
|
|
359
|
+
failed: readonly FailedInstance[],
|
|
360
|
+
): Promise<Schemas["WorkflowRequeueResponse"] | null> {
|
|
361
|
+
if (!failed.length) return null;
|
|
362
|
+
return ctx.step(stepKey, async (step) => {
|
|
363
|
+
this.requeued.push({
|
|
364
|
+
effectId: step.effectId,
|
|
365
|
+
workflowId: ctx.workflowId,
|
|
366
|
+
turn: ctx.turn,
|
|
367
|
+
failed: failed.map((f) => ({ ...f })),
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
attempt: 1,
|
|
371
|
+
not_before: "2026-01-01T00:01:00Z",
|
|
372
|
+
requeued: failed.map((f) => f.instance),
|
|
373
|
+
exhausted: [],
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
review(request: ReviewRequest): Promise<Schemas["OntologyChangeSuggestion"]>;
|
|
379
|
+
review(request: ReviewItemRequest): Promise<Schemas["ReviewItem"]>;
|
|
380
|
+
async review(
|
|
381
|
+
request: ReviewRequest | ReviewItemRequest,
|
|
382
|
+
): Promise<Schemas["OntologyChangeSuggestion"] | Schemas["ReviewItem"]> {
|
|
383
|
+
if ("kind" in request && request.kind !== "change") return this.question(request);
|
|
384
|
+
return this.suggestion(request as ReviewRequest);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The items filed under `keys`, of every status and kind. */
|
|
388
|
+
async reviewItems(keys: string[]): Promise<Schemas["ReviewItem"][]> {
|
|
389
|
+
return [...new Set(keys)].flatMap((key): Schemas["ReviewItem"][] => {
|
|
390
|
+
const request = this.questions.get(key);
|
|
391
|
+
if (request) return [this.itemOf(key, request)];
|
|
392
|
+
// A suggestion is a `change` item, answered as its status says.
|
|
393
|
+
const filed = this.reviews.get(key);
|
|
394
|
+
if (!filed) return [];
|
|
395
|
+
const decision =
|
|
396
|
+
filed.status === "accepted"
|
|
397
|
+
? "accept"
|
|
398
|
+
: filed.status === "dismissed"
|
|
399
|
+
? "reject"
|
|
400
|
+
: undefined;
|
|
401
|
+
return [
|
|
402
|
+
{
|
|
403
|
+
id: `rev_${key}`,
|
|
404
|
+
object: "review_item",
|
|
405
|
+
key,
|
|
406
|
+
kind: "change",
|
|
407
|
+
status: decision ? "answered" : filed.status === "superseded" ? "withdrawn" : "open",
|
|
408
|
+
title: filed.title,
|
|
409
|
+
...(decision ? { answer: { decision } } : {}),
|
|
410
|
+
} as unknown as Schemas["ReviewItem"],
|
|
411
|
+
];
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** A review item of another kind than a change: open, or answered by the rules. A key a
|
|
416
|
+
* suggestion holds is refused as the server refuses it (`409 review_kind_clash`). */
|
|
417
|
+
private question(request: ReviewItemRequest): Schemas["ReviewItem"] {
|
|
418
|
+
const key = request.key ?? `review-${this.questions.size}`;
|
|
419
|
+
if (this.reviews.has(key))
|
|
420
|
+
throw new LbbError(409, `key ${key} names a change item, not a ${request.kind} item`, {
|
|
421
|
+
type: "invalid_request_error",
|
|
422
|
+
code: "review_kind_clash",
|
|
423
|
+
message: `key ${key} names a change item, not a ${request.kind} item`,
|
|
424
|
+
});
|
|
425
|
+
// As the server does: an answered item stays as answered, unless new
|
|
426
|
+
// evidence brings another proposal for an answer in force, which asks
|
|
427
|
+
// again and keeps that answer as `previous_answer`.
|
|
428
|
+
const before = this.questions.get(key);
|
|
429
|
+
if (before) {
|
|
430
|
+
const was = this.itemOf(key, before);
|
|
431
|
+
const answer = was.status === "answered" ? was.answer : undefined;
|
|
432
|
+
const inForce = answer?.decision === "accept" || answer?.decision === "correct";
|
|
433
|
+
const evidenceChanged =
|
|
434
|
+
Boolean(before.evidence_hash && request.evidence_hash) &&
|
|
435
|
+
before.evidence_hash !== request.evidence_hash;
|
|
436
|
+
const held = answer?.value ?? before.question.value;
|
|
437
|
+
const asked = request.question.value;
|
|
438
|
+
if (inForce && evidenceChanged && held !== asked && before.question.value !== asked)
|
|
439
|
+
this.previous.set(key, { ...answer!, value: held });
|
|
440
|
+
else if (answer) return was;
|
|
441
|
+
}
|
|
442
|
+
this.questions.set(key, request);
|
|
443
|
+
return this.itemOf(key, request);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private itemOf(key: string, request: ReviewItemRequest): Schemas["ReviewItem"] {
|
|
447
|
+
const previous = this.previous.get(key);
|
|
448
|
+
// An item asked again waits for a new answer.
|
|
449
|
+
const answer = previous ? undefined : this.rules.reviewAnswer?.(request);
|
|
450
|
+
const refused = answer ? this.rules.reviewRefused?.(request) : undefined;
|
|
451
|
+
return {
|
|
452
|
+
id: `rev_${key}`,
|
|
453
|
+
object: "review_item",
|
|
454
|
+
key,
|
|
455
|
+
kind: request.kind,
|
|
456
|
+
// A refused answer does not stand: the item is open again, as the server keeps it.
|
|
457
|
+
status: answer && !refused ? "answered" : "open",
|
|
458
|
+
title: request.title,
|
|
459
|
+
question: request.question,
|
|
460
|
+
...(request.evidence_hash ? { evidence_hash: request.evidence_hash } : {}),
|
|
461
|
+
...(previous ? { previous_answer: previous } : {}),
|
|
462
|
+
...(answer ? { answer } : {}),
|
|
463
|
+
...(refused ? { effects: { commit_error: refused, delivery: { state: "none" } } } : {}),
|
|
464
|
+
} as unknown as Schemas["ReviewItem"];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Files the item; the wait returns the rules' answer, else it times out. */
|
|
468
|
+
async waitForReview(
|
|
469
|
+
ctx: WorkflowContext,
|
|
470
|
+
key: string,
|
|
471
|
+
request: ReviewItemRequest,
|
|
472
|
+
): Promise<ReviewWaitResult> {
|
|
473
|
+
const item = await ctx.step(`${key}:ask`, async () => this.question(request));
|
|
474
|
+
return item.answer
|
|
475
|
+
? { answered: true, answer: item.answer, item }
|
|
476
|
+
: { answered: false, answer: null, item };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private async suggestion(request: ReviewRequest): Promise<Schemas["OntologyChangeSuggestion"]> {
|
|
317
480
|
const key = request.key ?? `review-${this.reviews.size}`;
|
|
318
481
|
const status = this.reviews.get(key)?.status ?? this.rules.reviewStatus?.(request) ?? "open";
|
|
319
482
|
this.reviews.set(key, { ...request, status });
|
|
@@ -324,6 +487,38 @@ export class MockActivities implements LbbActivities {
|
|
|
324
487
|
} as unknown as Schemas["OntologyChangeSuggestion"];
|
|
325
488
|
}
|
|
326
489
|
|
|
490
|
+
/** The review items of `reviews`, newest first, filtered as the route filters them. */
|
|
491
|
+
async listReviews(
|
|
492
|
+
options: { originId?: string; status?: string; key?: string; limit?: number } = {},
|
|
493
|
+
): Promise<Schemas["OntologyChangeSuggestionList"]> {
|
|
494
|
+
const items = [...this.reviews.entries()]
|
|
495
|
+
.reverse()
|
|
496
|
+
.filter(
|
|
497
|
+
([key, item]) =>
|
|
498
|
+
(options.key === undefined || key === options.key) &&
|
|
499
|
+
(options.status === undefined || item.status === options.status) &&
|
|
500
|
+
(options.originId === undefined || item.origin?.id === options.originId),
|
|
501
|
+
)
|
|
502
|
+
.slice(0, options.limit ?? 100);
|
|
503
|
+
return {
|
|
504
|
+
suggestions: items.map(([key, item]) => ({
|
|
505
|
+
suggestion_id: key,
|
|
506
|
+
key,
|
|
507
|
+
status: item.status,
|
|
508
|
+
title: item.title,
|
|
509
|
+
anchor: item.anchor ?? { kind: "ontology" },
|
|
510
|
+
})),
|
|
511
|
+
} as unknown as Schemas["OntologyChangeSuggestionList"];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The review item filed under `key`, or null. */
|
|
515
|
+
async reviewItem(key: string): Promise<Schemas["OntologyChangeSuggestion"] | null> {
|
|
516
|
+
const item = this.reviews.get(key);
|
|
517
|
+
return item
|
|
518
|
+
? ({ ...item, suggestion_id: key, key } as unknown as Schemas["OntologyChangeSuggestion"])
|
|
519
|
+
: null;
|
|
520
|
+
}
|
|
521
|
+
|
|
327
522
|
async evolve(ops: Schemas["OntologyEvolveOp"][], options: { dryRun?: boolean } = {}) {
|
|
328
523
|
if (!options.dryRun) this.evolved.push(ops);
|
|
329
524
|
const answer = this.rules.evolve?.(ops) ?? {};
|
|
@@ -358,10 +553,23 @@ export interface MockFile {
|
|
|
358
553
|
* files: `files.content` and `workflows.derivations.check` / `record`, the
|
|
359
554
|
* shape of an `LbbClient`. `upload` puts the file's `Document` in `graph`
|
|
360
555
|
* with its name, SHA-256 and media type, as the upload route does.
|
|
556
|
+
*
|
|
557
|
+
* Each upload has its `uploaded_at`, as the server's file record: an upload
|
|
558
|
+
* of a live file keeps it, an upload after `remove` gets a new one. A
|
|
559
|
+
* derivation belongs to the upload of its record. The check names the
|
|
560
|
+
* upload of a live file; a removed file has no upload and no derivation.
|
|
561
|
+
* A record that names another upload than the live one throws `LbbError`
|
|
562
|
+
* 409 `derivation_upload_changed`.
|
|
361
563
|
*/
|
|
362
564
|
export class MockContent {
|
|
363
565
|
readonly held = new Map<string, MockFile>();
|
|
364
566
|
readonly recorded = new Map<string, Schemas["Derivation"]>();
|
|
567
|
+
/** The upload of each content's file, live or removed. */
|
|
568
|
+
readonly uploads = new Map<string, { uploadedAt: string; live: boolean }>();
|
|
569
|
+
/** The upload each recorded derivation belongs to. */
|
|
570
|
+
private readonly stamps = new Map<string, string | undefined>();
|
|
571
|
+
/** Every derivation record request, in order. */
|
|
572
|
+
readonly records: Schemas["DerivationRecordRequest"][] = [];
|
|
365
573
|
/** Reads of a file's bytes. */
|
|
366
574
|
reads = 0;
|
|
367
575
|
/** Derivation checks. */
|
|
@@ -373,6 +581,11 @@ export class MockContent {
|
|
|
373
581
|
upload(name: string, body: string | Uint8Array, mediaType: string): MockFile {
|
|
374
582
|
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
375
583
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
584
|
+
if (!this.uploads.get(sha256)?.live) {
|
|
585
|
+
this.newUploads += 1;
|
|
586
|
+
const at = new Date(Date.UTC(2026, 9, 15, 9) + this.newUploads * 1_000);
|
|
587
|
+
this.uploads.set(sha256, { uploadedAt: at.toISOString(), live: true });
|
|
588
|
+
}
|
|
376
589
|
const doc = this.graph.entity({ type: "Document", name, key: `file:${sha256}` });
|
|
377
590
|
doc.name = name;
|
|
378
591
|
Object.assign(doc.properties, {
|
|
@@ -386,6 +599,22 @@ export class MockContent {
|
|
|
386
599
|
return file;
|
|
387
600
|
}
|
|
388
601
|
|
|
602
|
+
/** Uploads that gave a content a new `uploaded_at`. */
|
|
603
|
+
private newUploads = 0;
|
|
604
|
+
|
|
605
|
+
/** Delete a file, as `DELETE /v1/files`: its bytes are no longer read; its `Document` stays here. */
|
|
606
|
+
remove(sha256: string): void {
|
|
607
|
+
const upload = this.uploads.get(sha256);
|
|
608
|
+
if (!upload?.live) throw new Error(`no live file ${sha256}`);
|
|
609
|
+
this.uploads.set(sha256, { ...upload, live: false });
|
|
610
|
+
this.held.delete(sha256);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** The `uploaded_at` of a content's file. */
|
|
614
|
+
uploadedAt(sha256: string): string | undefined {
|
|
615
|
+
return this.uploads.get(sha256)?.uploadedAt;
|
|
616
|
+
}
|
|
617
|
+
|
|
389
618
|
/** The byte range of each read, `null` for a whole file, in order. */
|
|
390
619
|
readonly ranges: Array<{ start: number; end: number | null } | null> = [];
|
|
391
620
|
/** The bytes all reads answered. */
|
|
@@ -428,14 +657,30 @@ export class MockContent {
|
|
|
428
657
|
derivations: {
|
|
429
658
|
check: async (key: Schemas["DerivationKey"]): Promise<Schemas["DerivationCheckResponse"]> => {
|
|
430
659
|
this.checks += 1;
|
|
431
|
-
const
|
|
432
|
-
|
|
660
|
+
const file = this.uploads.get(key.sha256);
|
|
661
|
+
const upload = file?.live ? file.uploadedAt : undefined;
|
|
662
|
+
const derivation =
|
|
663
|
+
file && !file.live ? undefined : this.current(derivationName(key), key.sha256);
|
|
664
|
+
return {
|
|
665
|
+
exists: Boolean(derivation),
|
|
666
|
+
...(derivation ? { derivation } : {}),
|
|
667
|
+
...(upload ? { content_uploaded_at: upload } : {}),
|
|
668
|
+
};
|
|
433
669
|
},
|
|
434
670
|
record: async (
|
|
435
671
|
request: Schemas["DerivationRecordRequest"],
|
|
436
672
|
): Promise<Schemas["DerivationRecordResponse"]> => {
|
|
673
|
+
this.records.push(request);
|
|
674
|
+
const named = request.content_uploaded_at;
|
|
675
|
+
const upload = this.uploads.get(request.sha256);
|
|
676
|
+
if (named && !(upload?.live && upload.uploadedAt === named))
|
|
677
|
+
throw new LbbError(409, "{}", {
|
|
678
|
+
code: "derivation_upload_changed",
|
|
679
|
+
message: `the file was deleted since upload ${named}: nothing was recorded`,
|
|
680
|
+
param: "content_uploaded_at",
|
|
681
|
+
});
|
|
437
682
|
const name = derivationName(request);
|
|
438
|
-
const kept = this.
|
|
683
|
+
const kept = this.current(name, request.sha256);
|
|
439
684
|
if (kept) return { recorded: false, derivation: kept };
|
|
440
685
|
const outputs = JSON.stringify(request.outputs ?? null);
|
|
441
686
|
if (outputs.length > 16 * 1024)
|
|
@@ -453,10 +698,19 @@ export class MockContent {
|
|
|
453
698
|
recorded_at: "2026-10-15T09:00:00Z",
|
|
454
699
|
};
|
|
455
700
|
this.recorded.set(name, derivation);
|
|
701
|
+
this.stamps.set(name, upload?.uploadedAt);
|
|
456
702
|
return { recorded: true, derivation };
|
|
457
703
|
},
|
|
458
704
|
},
|
|
459
705
|
};
|
|
706
|
+
|
|
707
|
+
/** The derivation `name` when it belongs to the current upload of its content. */
|
|
708
|
+
private current(name: string, sha256: string): Schemas["Derivation"] | undefined {
|
|
709
|
+
const derivation = this.recorded.get(name);
|
|
710
|
+
const upload = this.uploadedAt(sha256);
|
|
711
|
+
if (!derivation || (upload !== undefined && this.stamps.get(name) !== upload)) return undefined;
|
|
712
|
+
return derivation;
|
|
713
|
+
}
|
|
460
714
|
}
|
|
461
715
|
|
|
462
716
|
const derivationName = (key: Schemas["DerivationKey"]) =>
|
|
@@ -8,33 +8,47 @@ evolve operations and the quotes that support it; a person accepts it, and
|
|
|
8
8
|
the ontology changes.
|
|
9
9
|
|
|
10
10
|
The LBB-run version is the starter workflow `ontology.fit@v1`, the fit
|
|
11
|
-
sources of `/v1/ontology/fit-sources` (`docs/reference/fit-sources.md`).
|
|
11
|
+
sources of `/v1/ontology/fit-sources` (`docs/reference/fit-sources.md`). The
|
|
12
|
+
template follows its rules (`lbb-graph-query/src/fit_sources.rs`); its eval
|
|
13
|
+
runs both on the same recorded model answers and finds the same calls,
|
|
14
|
+
review items and counters ([`../_evals/README.md`](../_evals/README.md)).
|
|
12
15
|
|
|
13
16
|
## How it runs
|
|
14
17
|
|
|
15
|
-
The triggered workflow watches the class and its text field.
|
|
16
|
-
|
|
18
|
+
The triggered workflow watches the class and its text field. A turn first
|
|
19
|
+
reads the workflow's review items (`lbb.listReviews`): the titles of the open
|
|
20
|
+
ones and the names of the dismissed ones. Then, for each instance of the
|
|
21
|
+
message, two steps:
|
|
17
22
|
|
|
18
|
-
1. The
|
|
19
|
-
(`
|
|
20
|
-
|
|
21
|
-
and
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
1. **Propose.** The text is the field's values as LBB composes it
|
|
24
|
+
(`text: <values>`), read in chunks of 40,000 characters with 1,000 shared.
|
|
25
|
+
The reasoning model reads each chunk with LBB's prompt: the ontology (its
|
|
26
|
+
classes, relations with domain and range, properties, and the name style
|
|
27
|
+
it uses), and the changes already proposed and waiting for review, newest
|
|
28
|
+
support first, with the rejected names. It returns up to 12 proposals per
|
|
29
|
+
chunk: `add_class`, `add_property`, `add_relation` or `widen_relation`,
|
|
30
|
+
each with a sentence of the text as its quote. A chunk the model gives no
|
|
31
|
+
usable answer for counts as failed, and the instance goes on. Then the
|
|
32
|
+
checks without a model, in LBB's order: the shape (`shape`; a relation or
|
|
33
|
+
property name in the ontology's style, `ZUSTAENDIG` as `zustaendig`, and
|
|
34
|
+
a new relation with the name of an existing one widens it), one proposal
|
|
35
|
+
per change, the quote in the text with at least three words, and a dry run
|
|
36
|
+
(`lbb.evolve(change, { dryRun: true })`): a change the ontology has is
|
|
37
|
+
`already_covered`. The decision model scores how well each quote supports
|
|
38
|
+
its change; one below 0.5 is `weak_support`.
|
|
39
|
+
2. **File.** Each kept change goes into the review item of its key:
|
|
28
40
|
`fit:<workflow name>/add_class/mangel`,
|
|
29
41
|
`fit:<workflow name>/add_relation/mangel/zustaendig/firma`,
|
|
30
|
-
`fit:<workflow name>/widen_relation/betrifft-raum/mangel/x`.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
`fit:<workflow name>/widen_relation/betrifft-raum/mangel/x`. The item
|
|
43
|
+
keeps the best supported samples of every text that supports it (at most
|
|
44
|
+
20), and its rationale says how many instances support it. An item a
|
|
45
|
+
person decided is left alone. The next instance's prompt lists the
|
|
46
|
+
change.
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
The state counts what LBB's fit sources count: `instances`, `chunks`,
|
|
49
|
+
`failed_chunks`, `proposals`, `kept`, `filed`, `updated`, `quote_not_found`,
|
|
50
|
+
`invalid`, `already_covered` and `weak_support`. A turn reads at most 24
|
|
51
|
+
texts; the rest goes to a new turn.
|
|
38
52
|
|
|
39
53
|
## Settings
|
|
40
54
|
|
|
@@ -51,18 +65,13 @@ every key. The workflow writes no graph facts, so it owns nothing.
|
|
|
51
65
|
|
|
52
66
|
## What the LBB-run version adds
|
|
53
67
|
|
|
54
|
-
- **Support
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
names and does not propose a rejected change again.
|
|
62
|
-
- **Name style.** A new relation or property name is written in the
|
|
63
|
-
ontology's style (`works_in` or `WORKS_IN`).
|
|
64
|
-
- **Long texts.** A text past 40,000 characters is read in overlapping
|
|
65
|
-
chunks.
|
|
68
|
+
- **Support across all runs.** It keeps the instances that support each key
|
|
69
|
+
beside the source, so its count stays exact past 20 samples. The template
|
|
70
|
+
counts from the item: its count, and one more for an instance the samples
|
|
71
|
+
do not hold yet.
|
|
72
|
+
- **Reopening.** A dismissed change opens again when twice its support and
|
|
73
|
+
three more instances support it. The template leaves a dismissed change
|
|
74
|
+
dismissed.
|
|
66
75
|
- Its own monthly budget, a preview with the proposals of a sample, and
|
|
67
76
|
counters per outcome in the source's status.
|
|
68
77
|
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// `Frist`, `Status` and `Zuständig` propose its facts, a room in the same
|
|
4
4
|
// text widens `betrifft_raum`, a list of who was present proposes the
|
|
5
5
|
// relation `anwesend` the ontology has, and a text about the acceptance
|
|
6
|
-
// (`Abnahme`) gets a quote that is not in the text. The
|
|
7
|
-
//
|
|
6
|
+
// (`Abnahme`) gets a quote that is not in the text. The mock decision model
|
|
7
|
+
// finds every quote supports its change. The expected keys come from
|
|
8
|
+
// samples/expected.md.
|
|
8
9
|
import { test } from "node:test";
|
|
9
10
|
import assert from "node:assert/strict";
|
|
10
11
|
import { readFileSync } from "node:fs";
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
triggerMessage,
|
|
21
22
|
type MockRules,
|
|
22
23
|
} from "../_shared/testing.js";
|
|
23
|
-
import { createWorkflow, keyPart, quoteInText } from "./workflow.js";
|
|
24
|
+
import { chunkText, createWorkflow, displayName, keyPart, quoteInText } from "./workflow.js";
|
|
24
25
|
import { sampleOntology } from "./samples.js";
|
|
25
26
|
|
|
26
27
|
const SAMPLES = samplesDir(import.meta.url, "ontology-fit");
|
|
@@ -183,10 +184,18 @@ function rules(more: MockRules = {}): MockRules {
|
|
|
183
184
|
reason: (request) => ({
|
|
184
185
|
proposals: propose((JSON.parse(request.prompt) as { text: string }).text),
|
|
185
186
|
}),
|
|
187
|
+
decide: (request) =>
|
|
188
|
+
Object.fromEntries(
|
|
189
|
+
Object.keys(request.questions).map((id) => [
|
|
190
|
+
id,
|
|
191
|
+
{ choice: "supported", confidence: 0.9, probabilities: { supported: 0.9 } },
|
|
192
|
+
]),
|
|
193
|
+
),
|
|
186
194
|
evolve: (ops) => ({ no_op: ops.every((op) => covered(op, v)) }),
|
|
187
195
|
sparql: (query) =>
|
|
188
196
|
TEXTS.filter((t) => query.includes(iriOf(t.name))).map((t) => ({
|
|
189
197
|
s: iriOf(t.name),
|
|
198
|
+
class: "Baustellenbericht",
|
|
190
199
|
label: t.name,
|
|
191
200
|
text: t.text,
|
|
192
201
|
})),
|
|
@@ -211,7 +220,23 @@ test("the samples give the changes of expected.md, one review item per key", asy
|
|
|
211
220
|
);
|
|
212
221
|
assert.equal(EXPECTED_KEYS.length, 5);
|
|
213
222
|
assert.deepEqual([...lbb.reviews.keys()].sort(), [...EXPECTED_KEYS].sort());
|
|
214
|
-
|
|
223
|
+
// The counters of LBB's fit: the quote of Abnahme is not in its text, anwesend is covered
|
|
224
|
+
// (texts 1 and 2 list who was present).
|
|
225
|
+
assert.deepEqual(state, {
|
|
226
|
+
instances: 5,
|
|
227
|
+
chunks: 5,
|
|
228
|
+
failed_chunks: 0,
|
|
229
|
+
proposals: 22,
|
|
230
|
+
kept: 19,
|
|
231
|
+
filed: 5,
|
|
232
|
+
updated: 14,
|
|
233
|
+
reopened: 0,
|
|
234
|
+
quote_not_found: 1,
|
|
235
|
+
invalid: 0,
|
|
236
|
+
already_covered: 2,
|
|
237
|
+
weak_support: 0,
|
|
238
|
+
skipped: 0,
|
|
239
|
+
});
|
|
215
240
|
const mangel = lbb.reviews.get("fit:berichte/add_class/mangel")!;
|
|
216
241
|
assert.equal(mangel.workflow, "berichte");
|
|
217
242
|
assert.deepEqual(mangel.change, [{ op: "add_entity_type", name: "Mangel" }]);
|
|
@@ -230,7 +255,18 @@ test("a change names the classes it needs, and a widening keeps the relation's n
|
|
|
230
255
|
|
|
231
256
|
assert.deepEqual(lbb.reviews.get("fit:berichte/add_relation/mangel/zustaendig/firma")!.change, [
|
|
232
257
|
{ op: "add_entity_type", name: "Mangel" },
|
|
233
|
-
{
|
|
258
|
+
{
|
|
259
|
+
op: "add_relation",
|
|
260
|
+
name: "zustaendig",
|
|
261
|
+
domain: ["Mangel"],
|
|
262
|
+
range: ["Firma"],
|
|
263
|
+
cardinality: null,
|
|
264
|
+
temporal_semantics: null,
|
|
265
|
+
reducer: null,
|
|
266
|
+
inverse_name: null,
|
|
267
|
+
transitive: false,
|
|
268
|
+
symmetric: false,
|
|
269
|
+
},
|
|
234
270
|
]);
|
|
235
271
|
assert.deepEqual(lbb.reviews.get("fit:berichte/widen_relation/betrifft-raum/mangel/x")!.change, [
|
|
236
272
|
{ op: "add_entity_type", name: "Mangel" },
|
|
@@ -268,3 +304,18 @@ test("keys keep lowercase ASCII letters and digits, other runs as one dash", ()
|
|
|
268
304
|
assert.equal(keyPart("-"), "x");
|
|
269
305
|
assert.equal(keyPart("zuständig"), "zust-ndig");
|
|
270
306
|
});
|
|
307
|
+
|
|
308
|
+
test("a long text is read in chunks as LBB reads it, and a class name in its words", () => {
|
|
309
|
+
const sentence = "Der Mangel in Raum 0.12 ist behoben. ";
|
|
310
|
+
const long = sentence.repeat(3_000);
|
|
311
|
+
const chunks = chunkText(long);
|
|
312
|
+
assert.equal(chunks.length, 3);
|
|
313
|
+
assert.ok(chunks.every((c) => [...c].length <= 40_000));
|
|
314
|
+
// Each chunk ends after a sentence and shares 1,000 characters with the next.
|
|
315
|
+
assert.ok(chunks[0]!.endsWith("behoben."));
|
|
316
|
+
assert.equal(chunks[1]!.slice(0, 1_000), chunks[0]!.slice(-1_000));
|
|
317
|
+
assert.deepEqual(chunkText("kurz"), ["kurz"]);
|
|
318
|
+
assert.equal(displayName("customerIdentityData"), "customer identity data");
|
|
319
|
+
assert.equal(displayName("depends_on"), "depends on");
|
|
320
|
+
assert.equal(displayName("baustellenbericht"), "baustellenbericht");
|
|
321
|
+
});
|