Coverage for src/lilbee/retrieval/entities/schema.py: 100%
63 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Typed entity extraction: the schema model and the entities table.
3The extraction taxonomy is induced from the corpus, not fixed: a general NER
4tag set has no notion of the identifier types a specific corpus carries.
5Sync induces a schema from a corpus sample and applies it automatically; the
6schema is machine state, persisted inside the LanceDB index so it travels
7with the data and needs no management. There is nothing to review or edit:
8if induction quality needs improving, the fix belongs in induction itself.
9"""
11from __future__ import annotations
13import json
14import logging
15from enum import Enum
16from typing import TYPE_CHECKING
18import pyarrow as pa
19from pydantic import BaseModel, Field, field_validator
21from lilbee.retrieval.language import noun_variants
23if TYPE_CHECKING:
24 from lilbee.data.store import Store
25 from lilbee.data.store.types import EntitySchemaState
27log = logging.getLogger(__name__)
30class ExtractorKind(Enum):
31 """How a type's mentions are found, cheapest first."""
33 REGEX = "regex"
34 SPACY = "spacy"
35 LLM = "llm"
38class EntityType(BaseModel):
39 """One induced type: how to find it and what to call it in questions."""
41 name: str = Field(min_length=1, max_length=64)
42 kind: ExtractorKind
43 # REGEX kinds compile this; SPACY kinds name a spaCy label (PERSON, ORG,
44 # DATE, ...); LLM kinds carry a one-line description for the prompt.
45 pattern: str = ""
46 description: str = ""
47 # Question nouns that mean this type ("part number", "part numbers").
48 synonyms: list[str] = Field(default_factory=list)
50 @field_validator("name")
51 @classmethod
52 def _slugify(cls, v: str) -> str:
53 slug = "_".join(v.strip().lower().split())
54 if not slug.replace("_", "").isalnum():
55 raise ValueError(f"type name must be alphanumeric words: {v!r}")
56 return slug
59class EntitySchema(BaseModel):
60 """The induced extraction contract for one corpus."""
62 types: list[EntityType]
64 def type_for_noun(self, noun: str) -> EntityType | None:
65 """Resolve a question noun (singular or plural) to a type, if any."""
66 candidates = noun_variants(noun)
67 for entity_type in self.types:
68 names = {entity_type.name, entity_type.name.replace("_", " ")}
69 names.update(entity_type.synonyms)
70 expanded: set[str] = set()
71 for name in names:
72 expanded |= noun_variants(name)
73 if candidates & expanded:
74 return entity_type
75 return None
78def extractor_key(entity_type: EntityType) -> tuple[ExtractorKind, str]:
79 """What a type actually extracts, ignoring the name it was given.
81 Two types with the same kind and the same pattern (or, for LLM kinds,
82 the same description) find the same mentions, so they are the same type
83 wearing different names. Induction dedupes by this, and schema evolution
84 uses it to tell a genuinely new type from a rename of a known one.
85 """
86 signature = entity_type.pattern.strip() or entity_type.description.strip()
87 return entity_type.kind, signature.casefold()
90def merge_schemas(existing: EntitySchema, induced: EntitySchema) -> EntitySchema:
91 """Existing types plus any genuinely new ones from *induced*.
93 Union, not replace: existing types keep their names and positions so
94 answers stay stable across a re-induction, and a type the fresh sample
95 happens not to cover is never silently dropped (the documents it was
96 induced from are still in the index). Only extractors the schema has
97 never seen are appended.
98 """
99 known = {extractor_key(t) for t in existing.types}
100 known_names = {t.name for t in existing.types}
101 additions = [
102 t for t in induced.types if extractor_key(t) not in known and t.name not in known_names
103 ]
104 if not additions:
105 return existing
106 return EntitySchema(types=[*existing.types, *additions])
109def parse_schema(state: EntitySchemaState) -> EntitySchema | None:
110 """The schema carried by a persisted row, or ``None`` when unreadable.
112 Unreadable is logged, not raised: a corrupt row is machine state gone
113 wrong, so the lifecycle re-induces automatically instead of failing sync.
114 """
115 try:
116 return EntitySchema.model_validate_json(state["schema_json"])
117 except Exception:
118 log.warning("Persisted entity schema is unreadable; re-inducing", exc_info=True)
119 return None
122def load_schema(store: Store) -> EntitySchema | None:
123 """Read the persisted schema from the index, or ``None`` when never induced."""
124 state = store.entity_schema_state()
125 return parse_schema(state) if state is not None else None
128def save_schema(schema: EntitySchema, store: Store, *, applied: bool, source_count: int) -> None:
129 """Persist the schema into the index (stable key order).
131 ``source_count`` is the document count the schema was induced from, and
132 is the baseline the next sync compares against to decide the corpus has
133 drifted; it is required so a save can never silently record a baseline
134 of zero, which would read as a tiny corpus and re-induce every sync.
135 """
136 payload = json.dumps(schema.model_dump(mode="json"), sort_keys=True)
137 store.save_entity_schema(payload, applied=applied, source_count=source_count)
140def _entities_schema() -> pa.Schema:
141 return pa.schema(
142 [
143 pa.field("entity", pa.utf8()),
144 pa.field("type", pa.utf8()),
145 pa.field("normalized_value", pa.utf8()),
146 pa.field("source", pa.utf8()),
147 pa.field("page", pa.int32()),
148 pa.field("chunk_index", pa.int32()),
149 pa.field("confidence", pa.float32()),
150 ]
151 )