@littlebigbrain/create 0.5.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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "littlebigbrain": "0.30.0"
3
+ }
@@ -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
- this.graph.apply(typeof write === "function" ? await write(step) : write);
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
- async review(request: ReviewRequest): Promise<Schemas["OntologyChangeSuggestion"]> {
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) ?? {};
@@ -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. For each
16
- instance of a trigger message, one step:
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 reasoning model reads the text with the ontology
19
- (`lbb.ontology()`, as classes, relations with their domain and range,
20
- and properties) and the changes the earlier texts of the turn proposed,
21
- and returns up to 12 proposals: `add_class`, `add_property`,
22
- `add_relation` or `widen_relation`, each with a sentence of the text as
23
- its quote. The prompt and schema are the LBB version's.
24
- 2. Each proposal becomes a change (`shape`): its key, its title and its
25
- evolve operations. A class the change names that the ontology lacks is
26
- added first, and a new relation with the name of an existing one widens
27
- it. The key follows the LBB version:
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
- 3. A change is kept when its quote is in the text with at least three
32
- words, and a dry run (`lbb.evolve(change, { dryRun: true })`) says the
33
- ontology does not have it yet.
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
- A last step files one review item per key, with the quotes of every text of
36
- the turn that supports it. The same key again updates the item. A turn reads
37
- at most 48 texts; the rest goes to a new turn.
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 scores.** The decision model scores how well each quote
55
- supports its change; a change below 0.5 is dropped.
56
- - **Support across instances.** The instances that support each key are
57
- counted over every run, and a dismissed change opens again when twice its
58
- support and three more instances support it.
59
- - **The proposals of the source.** The open suggestions of the source and
60
- the names of the dismissed ones go into every prompt, so the model reuses
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 expected keys come
7
- // from samples/expected.md.
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
- assert.deepEqual(state, { texts: 5, proposals: 3 + 4 + 5 + 5 + 2, filed: 5 });
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
- { op: "add_relation", name: "zustaendig", domain: ["Mangel"], range: ["Firma"] },
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
+ });