Files
bastion-agent/pii/recall.py
T
2026-09-26 18:31:40 +00:00

158 lines
5.9 KiB
Python

"""Recall backends — the sensor behind the sterilizer's second pass.
pipeline.py needs one thing from a recall backend: a callable that takes
text and returns [(verbatim_span, TYPE), ...] with TYPE from
detect.PII_TYPES. Everything downstream (grounding, replacement, residual
loop) is the same whichever sensor produced the proposals, so swapping the
sensor never changes the safety story: a span that is not byte-for-byte in
the input is dropped, and the script does every mutation.
Two backends (docs/pii-sterilization.md, "Recall backends"):
llm the llama.cpp chat-completions path (llmclient.py) — a model
server at LLM_URL, grammar-constrained. Best recall; needs a
machine that can reach one.
gliner an in-process zero-shot NER model (GLiNER, Apache-2.0) run on
CPU — the shape a member's laptop can carry. Optional import:
if PII_RECALL=gliner and the package is absent the service
refuses to START (fail closed at boot, not at first request).
Selection is by env (PII_RECALL), never by the caller: a request cannot
downgrade the sensor. Stdlib-only here; the gliner package is the one
optional third-party import in the module and it is loaded lazily.
"""
from typing import Callable, List, Tuple
from . import llmclient
from .detect import PII_TYPES
from .llmclient import RecallError # noqa: F401 (re-exported: one except-clause for every backend)
Findings = List[Tuple[str, str]]
BACKENDS = ("llm", "gliner")
# GLiNER is zero-shot: the labels ARE the prompt. Plain-English phrases
# recall better than bare enum names; each maps back to the closed
# vocabulary the vault and the grammar use. "OTHER" is deliberately not a
# label — a catch-all label recalls everything and precision collapses.
GLINER_LABELS = {
"person name": "PERSON",
"email address": "EMAIL",
"phone number": "PHONE",
"street address": "ADDRESS",
"social security number": "SSN",
"date of birth": "DOB",
"credit card number": "CREDIT_CARD",
"bank account number": "BANK_ACCOUNT",
"ip address": "IP_ADDRESS",
"mac address": "MAC_ADDRESS",
"username": "USERNAME",
"password or api key or secret": "CREDENTIAL",
"medical condition or diagnosis": "MEDICAL",
"id number or passport or license number": "ID_NUMBER",
"employer or school or organization": "ORG",
"specific location that identifies a person": "LOCATION",
}
assert set(GLINER_LABELS.values()) <= set(PII_TYPES)
DEFAULT_GLINER_MODEL = "urchade/gliner_multi_pii-v1"
DEFAULT_GLINER_THRESHOLD = 0.5
DEFAULT_GLINER_VERIFY_THRESHOLD = 0.3
class LlmBackend:
name = "llm"
def __init__(self, llm_url, timeout):
self.llm_url = llm_url
self.timeout = timeout
def extract(self, text): # type: (str) -> Findings
return llmclient.extract(text, llm_url=self.llm_url, timeout=self.timeout)
def verify(self, text): # type: (str) -> Findings
return llmclient.verify(text, llm_url=self.llm_url, timeout=self.timeout)
def describe(self):
return "llm=%s" % self.llm_url
class GlinerBackend:
"""In-process GLiNER. The model object is created once at boot (that is
the fail-closed moment) and shared; predict_entities is called per
request with the label list above."""
name = "gliner"
def __init__(self, model_name, threshold, verify_threshold, loader=None):
self.model_name = model_name
self.threshold = float(threshold)
self.verify_threshold = float(verify_threshold)
load = loader or _load_gliner
try:
self.model = load(model_name)
except ImportError as e:
raise RecallError(
"PII_RECALL=gliner but the 'gliner' package is not installed "
"(pip install gliner); refusing to start without a recall pass"
) from e
except Exception as e: # model download / load failure
raise RecallError("gliner model %r failed to load: %s" % (model_name, e)) from e
def _predict(self, text, threshold): # type: (str, float) -> Findings
try:
ents = self.model.predict_entities(
text, list(GLINER_LABELS), threshold=threshold)
except Exception as e:
raise RecallError("gliner prediction failed: %s" % e) from e
out = []
seen = set()
for ent in ents:
try:
span, label = ent["text"], ent["label"]
except (KeyError, TypeError):
continue
typ = GLINER_LABELS.get(label)
if typ is None or not isinstance(span, str):
continue
key = (span, typ)
if key in seen:
continue
seen.add(key)
out.append(key)
return out
def extract(self, text):
return self._predict(text, self.threshold)
def verify(self, text):
return self._predict(text, self.verify_threshold)
def describe(self):
return "gliner=%s@%.2f" % (self.model_name, self.threshold)
def _load_gliner(model_name):
from gliner import GLiNER # optional dependency, imported here only
return GLiNER.from_pretrained(model_name)
def build_backend(env, loader=None):
"""Select and construct the recall backend from the environment.
Raises RecallError (fail closed) on an unknown backend or a backend
that cannot start."""
kind = env.get("PII_RECALL", "llm").strip().lower()
if kind == "llm":
return LlmBackend(env.get("LLM_URL", "http://llm:8080"),
float(env.get("PII_LLM_TIMEOUT", "300")))
if kind == "gliner":
return GlinerBackend(
env.get("GLINER_MODEL", DEFAULT_GLINER_MODEL),
env.get("GLINER_THRESHOLD", DEFAULT_GLINER_THRESHOLD),
env.get("GLINER_VERIFY_THRESHOLD", DEFAULT_GLINER_VERIFY_THRESHOLD),
loader=loader,
)
raise RecallError("PII_RECALL=%r is not one of %s" % (kind, BACKENDS))