cmboulanger's picture
Add detailed explanation
8a7ede1

Post-processing

Post-processing converts the LLM's raw text output into XML tags inserted at the correct positions in the source text. It is split into four focused modules that execute in sequence:

LLM response (string)
       β”‚
       β–Ό
 1. parse      β†’  list[SpanDescriptor]         (parser.py)
       β”‚
       β–Ό
 2. resolve    β†’  list[ResolvedSpan]            (resolver.py)
       β”‚
       β–Ό
 3. validate   β†’  list[ResolvedSpan] (filtered) (validator.py)
       β”‚
       β–Ό
 4. inject     β†’  annotated XML string          (injector.py)

1. Parse (parser.py)

Input: raw string returned by call_fn Output: list[SpanDescriptor]

Handles the messiness of real LLM output:

  • Fence stripping β€” models often wrap JSON in markdown code fences (```json … ```). These are detected and removed before parsing, regardless of the language tag used.
  • JSON extraction β€” the cleaned string is parsed with json.loads. Only a JSON array is accepted; any other top-level type raises ValueError.
  • Dict-to-span conversion β€” each dict is validated for required keys (element, text, context) and coerced to a SpanDescriptor. Dicts with missing or non-string values are silently dropped.
  • Retry on failure β€” for TEXT_GENERATION endpoints, if the initial parse fails the pipeline calls make_correction_prompt() and retries exactly once with the same call_fn. If the second parse also fails, the chunk is skipped with a warning.

2. Resolve (resolver.py)

Input: list[SpanDescriptor], plain source text Output: list[ResolvedSpan]

Converts context-anchored descriptors to absolute character offsets.

Why context anchoring?

LLMs cannot reliably count characters in long strings, so asking them to return numeric offsets produces frequent errors. Instead, each span is described with its surrounding text (SpanDescriptor.context). The resolver searches the source text for that context string to determine where the entity lives. This approach trades a small risk of positional ambiguity (two identical context strings in different positions) for a large gain in robustness. In practice, entity text is distinctive enough that collisions are rare.

Resolution algorithm

For each SpanDescriptor:

  1. Locate context β€” search for context in the source text:

    • First, try exact substring search (str.find).
    • If not found, fall back to fuzzy matching via rapidfuzz with a default similarity threshold of 0.92. Fuzzy-matched spans receive ResolvedSpan.fuzzy_match = True and appear in AnnotationResult.fuzzy_spans for human review.
    • If neither match succeeds, the span is silently discarded.
  2. Locate text within context β€” once the context window is pinned to a position in the source text, do a substring search for SpanDescriptor.text within that window.

    • If not found, the span is discarded.
  3. Compute offsets β€” the context window's start offset plus the text's offset within the window gives (start, end) in the original source text.

Fuzzy matching details

rapidfuzz's extractOne function scores candidate substrings using the partial_ratio scorer, which finds the best-matching substring of the target for the query string. The 0.92 threshold was chosen empirically to accept minor OCR errors, whitespace normalisation differences, and Unicode equivalences while rejecting clearly wrong matches.


3. Validate (validator.py)

Input: list[ResolvedSpan], TEISchema Output: list[ResolvedSpan] (filtered)

Three checks are applied. Spans failing any check are silently dropped and logged at WARNING level:

  1. Element exists β€” the span's element must appear in the schema's element list.
  2. Attribute names β€” every key in ResolvedSpan.attributes must be declared in the element's TEIAttribute list.
  3. Attribute values β€” if TEIAttribute.allowed_values is set, the value must be one of those strings.

Additionally, spans with invalid bounds (start < 0, end > len(text), or start >= end) are rejected.

Validation is a safety net against hallucinations: the LLM occasionally invents element tags or attribute names not in the schema. Dropping those silently keeps the output valid.


4. Inject (injector.py)

Input: plain source text, list[ResolvedSpan] Output: annotated XML string

Building the nesting tree

Spans are flat at this point (no children). The injector infers nesting geometrically: span A is a child of span B if B.start <= A.start and A.end <= B.end.

_build_nesting_tree implements a greedy algorithm:

  1. Sort spans by start offset, then by length descending (so parents β€” which are longer β€” sort before their children).
  2. For each span, find its tightest enclosing parent by scanning already-placed spans.
  3. Attach it as a child of that parent, or as a root-level span if no enclosing parent exists.

Overlapping spans β€” where two spans partially overlap without one containing the other β€” are detected and logged as warnings. The offending span (the one with the later start position) is skipped, since overlapping XML tags are not well-formed.

Recursive tag injection

_inject_recursive walks the nesting tree depth-first. At each node it emits:

  1. Any source text between the previous sibling's end and this span's start.
  2. The opening tag with any attributes, e.g. <persName ref="...">.
  3. Recursively, the content of all child spans.
  4. Any source text after the last child and before this span's end.
  5. The closing tag, e.g. </persName>.

Because the injector works from pre-computed offsets on the original source text, the text content is never altered β€” only tags are inserted. This preserves the exact wording, whitespace, and punctuation of the input.