Free-text defect entries bury part and serial numbers inside prose that no single regex captures cleanly, because technicians write p/n AS9410-22, part number as9410 22, and PN: AS-9410/22 for the same component within one work package. This page trains a constrained spaCy named-entity model to pull part numbers and serials out of that variability, then bolts on regex guardrails so a linguistically plausible span never enters the ledger as a spurious identifier. It is a specific technique within Regex & NLP Field Extraction, extending pattern matching to the cases rigid patterns miss.
Prerequisites & inputs
Named-entity recognition earns its place only where deterministic patterns fail. A compiled regex excels at fixed formats and is the right tool for structured tokens — the approach taken in Extracting ATA Chapter Codes with Python — but it degrades on free prose where the surrounding words, not the token shape alone, signal that a string is a part number. The strategy here is a hybrid: a spaCy pipeline learns the textual context around identifiers, and a regex guardrail runs afterward to reject any span whose structure cannot be a real part or serial number. The model proposes; the guardrail disposes.
The inputs are raw defect narratives, one per work-order line, plus a spaCy pipeline seeded with high-precision anchor patterns. The scenario uses Australian-registered VH-OQA under ATA 52-41 (cargo-door actuation), extracting cargo-door latch actuator PN AS9410-22 and its serial SN A4471J. The typed entities this stage emits feed straight into the canonical identity work of Mapping Airbus vs Boeing Part-Number Schemes in Python, so the labels must be clean before the resolver ever sees them.
NER extraction with regex guardrails
Defect text runs through the spaCy model, each candidate span is tested against a structural regex guardrail, and only spans that survive both become typed PART_NUMBER or SERIAL_NUMBER entities.
Step-by-step implementation
- Seed a constrained pipeline. Start from a blank English model and add an
entity_rulerwith high-precision token patterns; in production this ruler seeds a statisticalnercomponent trained on annotated defect entries. - Normalize before tokenizing. Uppercase the input so patterns and guardrails share one case convention, and be deliberate about hyphenation, which the tokenizer treats inconsistently.
- Apply the guardrail. After the model proposes entities, test each span’s text against a structural regex: a part number must carry at least one digit; a serial must be a compact alphanumeric run.
- Type and validate the survivors. Wrap each accepted span in a Pydantic model so the label, text, and character offsets are contract-checked before handoff, satisfying the
§ 43.9(c)requirement to record work descriptions exactly.
from __future__ import annotations
import logging
import re
import spacy
from pydantic import BaseModel, ConfigDict, Field, field_validator
from spacy.language import Language
from spacy.tokens import Doc
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("mro.ner")
# Guardrails run AFTER the model to reject linguistically-plausible but
# structurally-invalid spans. Per FAA 14 CFR § 43.9(c), recorded identifiers
# must be exact, so a bare English word is never accepted as a part number.
_PN_GUARD = re.compile(r"^(?=.*\d)[A-Z0-9][A-Z0-9\-]{3,23}$")
_SN_GUARD = re.compile(r"^[A-Z0-9]{4,20}$")
class ExtractedEntity(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
label: str = Field(pattern=r"^(PART_NUMBER|SERIAL_NUMBER)$")
text: str = Field(min_length=4, max_length=24)
start_char: int = Field(ge=0)
end_char: int = Field(gt=0)
@field_validator("text", mode="before")
@classmethod
def normalize(cls, v: str) -> str:
return v.upper().strip()
def build_extractor() -> Language:
"""Blank English pipeline with a constrained entity ruler. The regex token
patterns act as high-precision anchors around which a trained statistical
ner component would generalize on real annotated data."""
nlp = spacy.blank("en")
ruler = nlp.add_pipe("entity_ruler")
ruler.add_patterns(
[
{"label": "PART_NUMBER", "pattern": [{"TEXT": {"REGEX": r"^[A-Z]{2,4}\d{3,6}[A-Z]?$"}}]},
{"label": "PART_NUMBER", "pattern": [{"TEXT": {"REGEX": r"^\d{3,4}-\d{2,4}$"}}]},
{"label": "SERIAL_NUMBER", "pattern": [{"TEXT": {"REGEX": r"^[A-Z]{0,3}\d{3,}[A-Z0-9]*$"}}]},
]
)
return nlp
def extract_entities(nlp: Language, text: str) -> list[ExtractedEntity]:
doc: Doc = nlp(text.upper())
results: list[ExtractedEntity] = []
for ent in doc.ents:
guard = _PN_GUARD if ent.label_ == "PART_NUMBER" else _SN_GUARD
if not guard.match(ent.text):
logger.warning("guardrail rejected %s span=%r", ent.label_, ent.text)
continue
results.append(
ExtractedEntity(
label=ent.label_,
text=ent.text,
start_char=ent.start_char,
end_char=ent.end_char,
)
)
logger.info("accepted %d entities from %d chars", len(results), len(text))
return results
Worked example
Take the defect line Cargo door latch actuator PN AS9410-22 unserviceable, SN A4471J replaced for VH-OQA. After uppercasing, the entity ruler proposes AS9410 as a PART_NUMBER candidate (the tokenizer splits on the hyphen, a nuance addressed below) and A4471J as a SERIAL_NUMBER. The part-number guardrail _PN_GUARD confirms AS9410 contains a digit and is well-formed; the serial guardrail _SN_GUARD accepts A4471J as a compact alphanumeric run. Both survive and are wrapped as typed ExtractedEntity records with their character offsets preserved for traceability back to the source line.
Contrast that with the line Actuator ARM was bent per finding where an over-eager pattern might propose ARM as a part number. _PN_GUARD requires at least one digit via its (?=.*\d) lookahead, so ARM fails the guardrail, logs guardrail rejected PART_NUMBER span='ARM', and never reaches the ledger. This is the division of labor that makes the hybrid trustworthy: the model brings recall over messy prose, and the regex guardrail restores the precision an airworthiness record demands.
Edge cases & compliance gotchas
- Tokenizer hyphen splitting. spaCy’s default tokenizer often breaks
AS9410-22intoAS9410,-,22, truncating the part number. Add a custom tokenizer rule or a post-match merge so the guardrail sees the full identifier, or the ledger stores a partial P/N. - Unicode diacritics in adjacent fields. Technician names like
José Núñezsit beside identifiers in the same sentence; normalize identifiers to ASCII-uppercase only, and never strip diacritics from the surrounding name text, which the maintenance record must preserve verbatim. - Serial-part ambiguity. A token like
A4471Jcan read as either a short part number or a serial depending on context; rely on the trained model’s contextual features rather than shape alone, and record the deciding context for audit. - OCR homoglyphs. Scanned entries turn
0/Oand1/Iinto look-alikes, soAS941O-22may slip past a lax guardrail; keep the guardrail strict and route rejected spans to human review rather than guessing a substitution.
A constrained NER model paired with structural guardrails extracts identifiers from the free text that defeats regex alone, without surrendering the determinism that regulatory records require. By letting the model generalize over how technicians actually write and letting the regex enforce what a valid part or serial number can structurally be, the pipeline captures the full spread of real-world phrasing while keeping every accepted entity defensible in an audit. In line with AC 43-9C guidance on maintenance record entries, spans that fail the guardrail are surfaced for review rather than silently discarded, closing the loop between recall and compliance.