@skaile/forge-compose
v0.8.0
Published
Corpus to composed text: tag selectors over case profiles, template/instance/example/fact document conventions, example ranking and a pluggable segmentation pipeline. Framework-free; model access is injected.
Readme
@skaile/forge-compose
Corpus → example blocks → tagged templates → generated text. Framework-free: no editor, UI, AI SDK or network dependency. Model access is one injected function (Generate), embeddings one injected Embedder.
| Entry | Contents |
|---|---|
| @skaile/forge-compose | Generate seam; selector calculus on forge-tags strings: parseSelector, gate, fit, rank, selectorOfCase, icdTag, icdHierarchy |
| @skaile/forge-compose/documents | Document conventions on plain ProseMirror JSON: kinds section/slot/source/fact/example, derivationFrom, slotsOf, templateIssues, instanceFromTemplate, missingSlotBlocks, elementTitle, templateSlotOf, examplesOf, exampleFromSlot, exampleFromProposal, factsOf, factNode, entryAllowed, rankLibrary, materialFor, previousVersionOf, materialToData |
| @skaile/forge-compose/segment | Segmentation pipeline: splitters, similarity merge/breakpoints, labelers (rule, embedding, classifier seam, LLM via Generate), resolve, scorers, defaultPipeline, segment, proposals |
| @skaile/forge-compose/corpus | Learning from finished documents: gapClusters (text the template has no slot for → slot candidates), repeatPatterns (headings repeated per facet value → je slot candidates), variantSplits (examples of one slot that differ by facet value → condition or variants), templateClusters (documents built alike → template candidates) |
Case distinction
A template holds slot blocks; slots sharing an identifier are variants. A case is a CaseProfile { tags, data }. instanceFromTemplate keeps, per identifier, the variant whose tags and meta.selector (#muss ~bevorzugt ~gewichtet*2 -nicht) pass gate and rank highest by fit against the case tags. ICD codes are tags icd/f32-1; icdHierarchy makes icd/f32 and icd/f3 its ancestors.
A slot with meta.je (a facet name: icd, test, thema) repeats once per case tag of that facet, in case-tag order: with je: icd and case tags icd/f43-1, icd/f91-1 the instance holds two element blocks Diagnose: F43.1 and Diagnose: F91.1. Each carries meta.element (its case tag) and the tag row [element]; per element the variant is picked against the case with that element as its only tag of the facet. Without a case tag of the facet the slot is left out like an optional one. templateIssues(doc, { facets }) returns TemplateIssue { kind, slot, identifier, value?, message } per finding; kind is one of identifier-missing, tag-invalid, je-invalid (no facet name), je-unknown (none of the known facets), je-mixed (variants of one identifier over different facets), variant-duplicate; message is a German sentence for display.
missingSlotBlocks(document, template, case, options) lists the blocks a fresh instance would hold that the document lacks (keyed by identifier and element, so existing blocks stay and a new case tag yields its element block), each with the neighbour to insert it after or before.
templateSlotOf(block, template) answers the template variant an instance block was made from (its meta.derivation.from.block); a block whose derivation names no slot of the template (made before instances recorded an origin, generated from a source) gets the first variant of its identifier.
Library: filter, then weighted signals
The library has two roles of one shape (ExampleBlock, read by examplesOf(doc, id, kind)): examples (example, style only) and snippets (a host kind such as baustein, content to adopt). An entry carries its slot (meta.slot), tags, condition (meta.nur: all must hold, meta.nie: none may hold; a tag list or whitespace/comma separated text), element and origin (its meta.derivation.from).
rankLibrary(entries, target, case, options) first filters: no proposal, non-empty text, entryAllowed (the condition against the case tags ∪ the target's element, ICD ladder via the hierarchy) and, with a MaterialTarget, the same slot; with null it ranks across slots. The score is the weighted sum of signals whose raw values stay in parts:
| Signal | Value | Default weight |
|---|---|---|
| tags | fit of the case tags against the entry tags (icd/f91-1 vs icd/f91 ½, vs icd/f9 ⅓) | 1 |
| element | proximity of the target's element to the entry's own element (an entry without one: best over its tags, which for an entry from a je block hold every case tag); 0 without target element | 2 |
| fall | 1 when the entry was made from the target's document | 0.5 |
| person | 1 when sameOwner(origin.document); 0 without sameOwner | 0.5 |
| inhalt | the injected scorers (e.g. embeddingScorer), each under its own name, weight × its own | 1 |
| length | lengthScorer: bonus for typical length, 1 − \|len − median\| / median; fixed weight, outside SIGNAL_WEIGHTS | 0.25 |
Every RankedEntry carries max, the highest score attainable for that target and options: the weights of tags, length and the injected scorers, plus element with a target element, fall with a target document and person with sameOwner. score / max is the fit in [0, 1].
materialFor(target, case, facts, { examples, snippets, previous }, options) returns the target's facts, its previous version (previous, see below), the best snippets (snippetLimit, 3) and examples (exampleLimit, 3), all capped at limit (10) in that order; materialToData labels them Fakt n (<source>), Vorfassung – fortschreiben, nicht übernehmen (PREVIOUS_LABEL), Baustein n – übernehmen und anpassen, Beispiel n – nur Stil. exampleFromSlot records the block's element and tags (∪ the case tags) and its origin.
The previous version (Vorfassung) is the block of the same slot in an earlier document of the same person: text to continue and update, never to copy. library.previous lists those documents as { id, content }, newest first; previousVersionOf(target, documents) takes, from the first document that has one, the first slot block with the target's identifier, the same element (meta.element; none matches none) and non-empty text, skipping the target's own document. It returns { title, text, origin: { document, block } } or null. Which documents belong to the same person is the host's knowledge; the library never sees a person key.
Corpus: gaps become slot candidates
import { gapClusters } from '@skaile/forge-compose/corpus'
const clusters = await gapClusters(documents, template, { embedder })
// [{ members: [{ documentId, blockId, heading, identifier, text }], position: { after, before }, frequency, optional }]documents are the case documents of one template ({ id, content }). A gap is a slot block whose meta.identifier is empty or unknown to the template and whose body holds text; slot blocks with a known identifier are anchors, content outside slot blocks is not looked at. The heading of a gap is its title, else the title of the enclosing section, else its identifier.
Gaps are clustered by average linkage over (1 − headingWeight) · cosine(heading + text) + headingWeight · Jaccard(heading words) (one batched embed call), merging while the similarity reaches threshold. A cluster counts when it spans minDocuments distinct documents; frequency = those documents / all documents, optional = frequency < optionalBelow. The position is the lower median of the template ranks of the anchors before (after) and behind (before) each member; no anchor = start or end. The result is deterministic for a given input order, most frequent cluster first.
| Option | Default |
|---|---|
| threshold | 0.55 (model-dependent) |
| headingWeight | 0.3 |
| minDocuments | 3 |
| optionalBelow | 0.8 |
Corpus: repeated headings become je slots
import { repeatPatterns } from '@skaile/forge-compose/corpus'
const patterns = repeatPatterns(documents, template, { vocabulary: ['test/cbcl', 'test/wisc-v'] })
// [{ facet: 'icd', pattern: 'Diagnose: {wert}', identifiers, values: [{ tag: 'icd/f91-1', count }], members: [{ documentId, blockId, heading, tag }], position, frequency }]A slot block with body text is looked at under its heading (title, else the title of the enclosing section). The heading must hold exactly one value: an ICD code (icdAnchorRule, facet icd) or a vocabulary tag whose last segment occurs as a word (test/wisc-v matches WISC-V, case-insensitive; facet = first segment). Headings group by facet and the lowercase words before the value; the text behind the value (diagnosis or test name) is free. A group counts with minDocuments (3) documents and minValues (2) distinct values, and is skipped when one of its identifiers already is a template slot with je of that facet. pattern replaces the value by {wert} and keeps the text behind it only when all members share it. The position is computed as for gaps with the group's own identifiers left out of the template order. Synchronous; most frequent group first.
Corpus: examples that differ by facet become conditions or variants
import { variantSplits } from '@skaile/forge-compose/corpus'
const splits = await variantSplits(examplesOfOneSlot, { embedder })
// [{ facet: 'icd', groups: [{ value: 'icd/f4', members, centroidSimilarity, nearest, separation }], separation, suggestion: 'bedingung' }]Input: the examples of one slot ({ documentId, blockId, text, tags }). Per facet in facets the examples are grouped by their single value of it (ICD: the chapter, icd/f91-1 → icd/f9; other facets: the first two segments); examples with none or several values are left out, groups below minPerGroup too, and a facet needs two groups. The separation of two groups is the cosine distance of their centroids (unit embeddings) over the mean of their spreads (mean distance of a member to its centroid); a group's separation is the one to its nearest group. A group reaching minSeparation is separated: exactly one → bedingung (that group gets a condition), two or more → variante (one variant each); two groups are always both or neither. One batched embed call, none when no facet qualifies; separated groups first.
| Option | Default |
|---|---|
| facets | ['alter', 'icd'] |
| minPerGroup | 3 |
| minSeparation | 1 (centroids as far apart as a text from its own centroid) |
Corpus: documents built alike become template candidates
import { templateClusters } from '@skaile/forge-compose/corpus'
const clusters = templateClusters(documents)
// [{ members: ['d1', …], frequency, slots: [{ identifier, title, frequency, optional, rank }], commonTags }]A document's slot set is the distinct identifiers of its slot blocks with body text. Documents are clustered by average linkage over the Jaccard of their slot sets, merging while it reaches threshold; a cluster counts with minDocuments members. Per cluster every identifier gets frequency (members using it), optional (frequency < optionalBelow), rank (median position among a member's distinct identifiers) and title (most frequent slot title); slots are ordered by rank, then frequency. commonTags are the case tags (CorpusDocument.tags) of at least commonTagShare of the members. Synchronous; largest cluster first.
| Option | Default |
|---|---|
| threshold | 0.5 |
| minDocuments | 3 |
| optionalBelow | 0.8 |
| commonTagShare | 0.8 |
Segmentation
import { defaultPipeline, segment, proposals } from '@skaile/forge-compose/segment'
const pipeline = defaultPipeline({ embedder, generate, rules })
const { units, decisions } = await segment(text, pipeline, { slots, tags }, { embedder })
const items = proposals(units, decisions)Labelers run in order; units already decided are kept out of the LLM batch. Accepted examples (status: 'geprüft') feed the catalog's examples, so the embedding labeler improves with each acceptance without a training step. classifierLabeler takes any predict(texts, labels) (for example a local ONNX model).
The editor integration (block types, editor actions, server operations, chat tools) is @skaile/forge-editor-compose.
