Skip to content

Wardcat

The main interface. Everything below is generated from the source docstrings.

wardcat.Wardcat

Wardcat(config_path=None, salt='')

Bases: EntityPolicyMixin

The main interface exposed to users.

Programmatic API (method chaining)::

import os
from wardcat import Wardcat, Entity, Action

guard = (
    # Read secrets from the environment in YOUR app — the library itself
    # never reads env vars; pass everything explicitly.
    Wardcat(salt=os.environ["WARDCAT_SALT"])
    .add_entity(Entity.EMAIL,       action=Action.HASH)
    .add_entity(Entity.CREDIT_CARD, action=Action.HASH)
    .remove_entity(Entity.ORG)
)
result = guard.scan(text)

Enable everything, then prune::

guard = Wardcat(salt="...").add_entity(Entity.ALL, action="hash")
guard.remove_entity(Entity.ORG)
guard.entity_policy()   # inspect: {"CREDIT_CARD": "hash", ...}

Declarative API (YAML)::

guard = Wardcat(config_path="config/my_policy.yaml")
result = guard.scan(text)

Configuration is explicit. The constructor takes only salt and an optional YAML config_path; every detection layer is configured with a fluent builder — :meth:with_ner and :meth:with_llm — or in the YAML file. The library does not read environment variables: read any secrets in your own application and hand them to the constructor. Builders are chainable and their order does not matter — the final configuration is what counts::

from wardcat import Wardcat, Language

# LLM layer
guard = Wardcat(salt="s").with_llm(model="llama3.1:8b")

# NER layer — needs an explicit model (wardcat ships no default). Choose
# one via language= (recommended) or spacy_model=:
guard = Wardcat(salt="s").with_ner(language=Language.DE, spacy_size="md")
guard = Wardcat(salt="s").with_ner(language=[Language.DE, Language.FR])
guard = Wardcat(salt="s").with_ner(spacy_model=["en_core_web_sm", "de_core_news_sm"])
# Supported languages: en, de, fr, es, it, nl, pt, tr; sizes sm/md/lg/trf.
# A named-but-missing model is auto-downloaded (auto_download=False to disable).
# For mixed-language text without extra models, use the LLM layer, whose
# prompt is multilingual.
Source code in src/wardcat/guard.py
def __init__(
    self,
    config_path: str | Path | None = None,
    salt: str = "",
) -> None:
    self._config = load_config(config_path)

    # Constructor arguments override YAML
    if salt:
        self._config["salt"] = salt

    # Detection layers are configured with the fluent builders — with_ner()
    # and with_llm() — or a YAML config_path, never constructor arguments.
    # Ensure the LLM sub-config exists for the YAML/builder path.
    self._config.setdefault("llm_detector", {})

    # A YAML config may still switch NER on; it must then name a model, since
    # wardcat ships no default. (The builder path always sets one.)
    if self._config.get("use_ner") and not (
        self._config.get("spacy_models") or self._config.get("spacy_model")
    ):
        raise ConfigError(
            "use_ner is on but no SpaCy model was given. Set spacy_model in the "
            "YAML config, or configure NER with with_ner(language=...) / "
            "with_ner(spacy_model=...); wardcat ships no default model."
        )

    # Entities whose enabled layer is missing — warned about once, lazily, at
    # scan time (see _warn_orphan_entities). Chains configure layers and
    # entities in any order, so an init/rebuild-time check would false-fire.
    self._orphan_warned: set[str] = set()

    # Entity types this caller configured by hand (add_entity / add_entities /
    # change_entity_action). The LLM layer ships its own default entity policy,
    # so this is the only way to tell "the user asked for PERSON" apart from
    # "with_llm() switched PERSON on" — see _warn_implicit_llm_entities.
    self._explicit_entities: set[str] = set()
    # A caller who passed a policy file chose every entity in it; nothing about
    # that configuration is implicit, so the warning is skipped for them.
    self._policy_from_file = config_path is not None
    self._implicit_llm_warned = False
    # Place names and group names moved out of ADDRESS/ORG into their own
    # types. A configuration written before that silently stops covering
    # them — see _warn_ner_type_split.
    self._ner_split_warned = False

    # Warn at most once when a hash action is active without a salt. Checked
    # in _rebuild() too, since entities are opt-in and usually added after init.
    self._salt_warned = False
    self._default_action_warned = False
    # A YAML `preset:` is the base; the file's own `entities:` win over it.
    preset_name = self._config.pop("preset", None)
    if preset_name:
        self._apply_preset(preset_name, keep_existing=True)
    self._rebuild()

scan

scan(text)

Scan text and return a ScanResult.

Source code in src/wardcat/guard.py
def scan(self, text: str) -> ScanResult:
    """Scan text and return a ScanResult."""
    self._warn_about_configuration()
    return self._engine.scan(text)

scan_async async

scan_async(text)

Async scan — uses native async I/O for the LLM backend when available.

CPU-bound detectors (regex, SpaCy NER) run in a thread pool; the LLM detector (if enabled) uses httpx.AsyncClient natively, so multiple concurrent calls do not block each other.

Source code in src/wardcat/guard.py
async def scan_async(self, text: str) -> ScanResult:
    """Async scan — uses native async I/O for the LLM backend when available.

    CPU-bound detectors (regex, SpaCy NER) run in a thread pool;
    the LLM detector (if enabled) uses ``httpx.AsyncClient`` natively,
    so multiple concurrent calls do not block each other.
    """
    self._warn_about_configuration()
    return await self._engine.scan_async(text)

is_sensitive

is_sensitive(text)

Return whether text contains sensitive information, judged semantically.

A general, holistic LLM decision — not the per-entity detection of :meth:scan. It asks the configured LLM whether the text as a whole contains sensitive information (PII, credentials, financial, health, or confidential business data) and returns a single True/False flag. Useful as a lightweight guardrail before sending text to an external service. :meth:classify asks the same question but for the kinds present; it is measured to be stricter, so for a yes/no gate this is the method to use.

Requires the LLM layer (:meth:with_llm); no entities need to be enabled and no regex/NER runs. Empty text is False. Fail-closed: if the LLM backend is unreachable the underlying error propagates, and an answer that cannot be read counts as sensitive, so a guardrail never silently treats sensitive text as safe.

Raises:

Type Description
ConfigError

if the LLM layer is not configured.

Source code in src/wardcat/guard.py
def is_sensitive(self, text: str) -> bool:
    """Return whether *text* contains sensitive information, judged semantically.

    A general, holistic LLM decision — *not* the per-entity detection of
    :meth:`scan`. It asks the configured LLM whether the text as a whole
    contains sensitive information (PII, credentials, financial, health, or
    confidential business data) and returns a single ``True``/``False`` flag.
    Useful as a lightweight guardrail before sending text to an external
    service. :meth:`classify` asks the same question but for the kinds
    present; it is measured to be stricter, so for a yes/no gate this is
    the method to use.

    Requires the LLM layer (:meth:`with_llm`); no entities need to be enabled
    and no regex/NER runs. Empty text is ``False``. Fail-closed: if the LLM
    backend is unreachable the underlying error propagates, and an answer
    that cannot be read counts as sensitive, so a guardrail never silently
    treats sensitive text as safe.

    :raises ConfigError: if the LLM layer is not configured.
    """
    detector = self._require_llm("is_sensitive")
    self._check_text_size(text)
    if not text.strip():
        return False
    language = self._config.get("llm_detector", {}).get("language")
    # Long inputs are chunked; a single sensitive chunk makes the whole
    # text sensitive, and we short-circuit on the first hit.
    for chunk, _ in chunk_by_paragraph(text, _SENSITIVITY_CHUNK_CHARS):
        if not chunk.strip():
            continue
        reply = detector.complete_messages(build_sensitivity_messages(chunk, language))
        if parse_sensitivity(reply):
            return True
    return False

is_sensitive_async async

is_sensitive_async(text)

Async variant of :meth:is_sensitive (native async LLM I/O when available).

Source code in src/wardcat/guard.py
async def is_sensitive_async(self, text: str) -> bool:
    """Async variant of :meth:`is_sensitive` (native async LLM I/O when available)."""
    detector = self._require_llm("is_sensitive_async")
    self._check_text_size(text)
    if not text.strip():
        return False
    language = self._config.get("llm_detector", {}).get("language")
    for chunk, _ in chunk_by_paragraph(text, _SENSITIVITY_CHUNK_CHARS):
        if not chunk.strip():
            continue
        reply = await detector.complete_messages_async(
            build_sensitivity_messages(chunk, language)
        )
        if parse_sensitivity(reply):
            return True
    return False

classify

classify(text)

Decide whether text is sensitive, and of what kind.

Like :meth:is_sensitive, one holistic LLM judgement over the whole text — but the answer names the categories present (pii, credentials, financial, health, special_category, business_confidential) and carries the model's one-line reason, so a caller can route on the kind: block health data, allow business data inside the company, log the rest. Long texts are judged in chunks and the categories merged; the reason is the first sensitive chunk's.

It is a separate call with its own prompt, not the source of :meth:is_sensitive: asked for structure, the model was measured to be more precise and less sensitive (on the benchmark, one false alarm in a hundred texts against eleven, but eight misses against one, mostly confidential business plans), and several times slower. Use :meth:is_sensitive as the gate and this for routing what it stops.

An answer that cannot be read is sensitive with the category "unknown" — the guardrail fails closed. reason may quote the text and is as sensitive as the input.

Raises:

Type Description
ConfigError

if the LLM layer is not configured.

Source code in src/wardcat/guard.py
def classify(self, text: str) -> SensitivityVerdict:
    """Decide whether *text* is sensitive, and of what kind.

    Like :meth:`is_sensitive`, one holistic LLM judgement over the whole
    text — but the answer names the categories present (``pii``,
    ``credentials``, ``financial``, ``health``, ``special_category``,
    ``business_confidential``) and carries the model's one-line reason, so a
    caller can route on the kind: block health data, allow business data
    inside the company, log the rest. Long texts are judged in chunks and the
    categories merged; the reason is the first sensitive chunk's.

    It is a separate call with its own prompt, not the source of
    :meth:`is_sensitive`: asked for structure, the model was measured to be
    more precise and less sensitive (on the benchmark, one false alarm in a
    hundred texts against eleven, but eight misses against one, mostly
    confidential business plans), and several times slower. Use
    :meth:`is_sensitive` as the gate and this for routing what it stops.

    An answer that cannot be read is *sensitive* with the category
    ``"unknown"`` — the guardrail fails closed. ``reason`` may quote the
    text and is as sensitive as the input.

    :raises ConfigError: if the LLM layer is not configured.
    """
    detector = self._require_llm("classify")
    self._check_text_size(text)
    if not text.strip():
        return SensitivityVerdict(False)
    language = self._config.get("llm_detector", {}).get("language")
    verdicts = []
    for chunk, _ in chunk_by_paragraph(text, _SENSITIVITY_CHUNK_CHARS):
        if not chunk.strip():
            continue
        reply = detector.complete_messages(build_classification_messages(chunk, language))
        verdicts.append(parse_classification(reply))
    return _merge_verdicts(verdicts)

classify_async async

classify_async(text)

Async variant of :meth:classify.

Source code in src/wardcat/guard.py
async def classify_async(self, text: str) -> SensitivityVerdict:
    """Async variant of :meth:`classify`."""
    detector = self._require_llm("classify_async")
    self._check_text_size(text)
    if not text.strip():
        return SensitivityVerdict(False)
    language = self._config.get("llm_detector", {}).get("language")
    verdicts = []
    for chunk, _ in chunk_by_paragraph(text, _SENSITIVITY_CHUNK_CHARS):
        if not chunk.strip():
            continue
        reply = await detector.complete_messages_async(
            build_classification_messages(chunk, language)
        )
        verdicts.append(parse_classification(reply))
    return _merge_verdicts(verdicts)

scan_batch

scan_batch(texts, *, max_workers=None)

Scan multiple texts in parallel using a thread pool.

Each text is scanned independently; an error in a single item does not affect the others — the original text is returned untouched for any item that fails.

Parameters:

Name Type Description Default
texts list[str]

List of texts to scan

required
max_workers int | None

Number of parallel threads. Defaults to the scan_batch_workers config value (default: 4).

None

Returns:

Type Description
list[ScanResult]

List of ScanResult in the same order as texts

Source code in src/wardcat/guard.py
def scan_batch(self, texts: list[str], *, max_workers: int | None = None) -> list[ScanResult]:
    """
    Scan multiple texts in parallel using a thread pool.

    Each text is scanned independently; an error in a single item does
    not affect the others — the original text is returned untouched
    for any item that fails.

    :param texts:       List of texts to scan
    :param max_workers: Number of parallel threads. Defaults to the
                        ``scan_batch_workers`` config value (default: 4).
    :returns:           List of ``ScanResult`` in the same order as ``texts``
    """
    if not texts:
        return []

    # The engine is called directly, so the one-time configuration warnings
    # that scan() logs have to be raised here, once for the whole batch.
    self._warn_about_configuration()
    workers = max_workers or self._config.get("scan_batch_workers", 4)
    # Batched layers (SpaCy NER via nlp.pipe) see the whole list in one
    # pass; the LLM layer runs per text in `workers` threads, bounded at the
    # backend by its own max_concurrency. Strict mode propagates from here
    # too: no degraded result is filed under scan_error.
    return self._engine.scan_many(texts, workers=workers)

scan_batch_async async

scan_batch_async(texts, *, max_workers=None)

Scan multiple texts concurrently using native async.

Each text is scanned independently via :meth:scan_async; all are run concurrently with asyncio.gather. Errors in individual items are caught — the original text is returned with scan_error set.

Source code in src/wardcat/guard.py
async def scan_batch_async(
    self, texts: list[str], *, max_workers: int | None = None
) -> list[ScanResult]:
    """Scan multiple texts concurrently using native async.

    Each text is scanned independently via :meth:`scan_async`; all are
    run concurrently with ``asyncio.gather``.  Errors in individual items
    are caught — the original text is returned with ``scan_error`` set.
    """
    if not texts:
        return []
    # Bounded like the sync batch: `scan_batch_workers` texts in flight at
    # once, so a large list does not fan out into one request per item.
    gate = asyncio.Semaphore(max_workers or self._config.get("scan_batch_workers", 4))

    async def _one(idx: int, text: str) -> tuple[int, ScanResult]:
        try:
            async with gate:
                return idx, await self.scan_async(text)
        except DegradedScanError:
            raise
        except Exception as exc:
            logger.error(
                "scan_batch_async item %d failed (%s: %s), returning original text.",
                idx,
                type(exc).__name__,
                exc,
            )
            return idx, ScanResult(
                original_text=text,
                sanitized_text=text,
                violations=[],
                scan_error=f"{type(exc).__name__}: {exc}",
                warnings=list(self._engine.build_warnings),
            )

    pairs = await asyncio.gather(*(_one(i, t) for i, t in enumerate(texts)))
    results: list[ScanResult | None] = [None] * len(texts)
    for idx, result in pairs:
        results[idx] = result
    return results  # type: ignore[return-value]

supported_entities staticmethod

supported_entities(layer=None)

Return the entity types wardcat can detect (discoverability helper).

::

Wardcat.supported_entities()            # every known entity type
Wardcat.supported_entities("regex")     # only what the regex layer detects
Wardcat.supported_entities("ner")       # PERSON, ORG, ADDRESS
Wardcat.supported_entities("llm")       # contextual/semantic types

Parameters:

Name Type Description Default
layer str | Layer | None

None → all known types; or one of "regex", "ner", "llm" for that layer's set.

None

Raises:

Type Description
ConfigError

if layer is not a known layer.

Source code in src/wardcat/guard.py
@staticmethod
def supported_entities(layer: str | Layer | None = None) -> frozenset[str]:
    """Return the entity types wardcat can detect (discoverability helper).

    ::

        Wardcat.supported_entities()            # every known entity type
        Wardcat.supported_entities("regex")     # only what the regex layer detects
        Wardcat.supported_entities("ner")       # PERSON, ORG, ADDRESS
        Wardcat.supported_entities("llm")       # contextual/semantic types

    :param layer: ``None`` → all known types; or one of ``"regex"``,
                  ``"ner"``, ``"llm"`` for that layer's set.
    :raises ConfigError: if ``layer`` is not a known layer.
    """
    if layer is None:
        return frozenset(KNOWN_ENTITY_TYPES)
    if isinstance(layer, Layer):
        layer = layer.value
    if layer not in LAYER_ENTITIES:
        raise ConfigError(f"Unknown layer {layer!r}. Valid layers: {sorted(VALID_LAYERS)}")
    return LAYER_ENTITIES[layer]

with_ner

with_ner(
    *,
    language=None,
    spacy_model=None,
    spacy_size="sm",
    auto_download=True,
)

Enable the SpaCy NER layer with an explicit model. Supports chaining.

Mirrors :meth:with_llm. Pass language= (recommended; a list enables multilingual NER) or spacy_model= (explicit package name(s)).

::

guard = Wardcat(salt="s").with_ner(language=Language.EN)
guard = Wardcat(salt="s").with_ner(spacy_model=["en_core_web_sm", "de_core_news_sm"])

Raises:

Type Description
ConfigError

if neither language nor spacy_model is given.

Source code in src/wardcat/guard.py
def with_ner(
    self,
    *,
    language: str | Language | list[str | Language] | None = None,
    spacy_model: str | list[str] | None = None,
    spacy_size: str = "sm",
    auto_download: bool = True,
) -> Wardcat:
    """
    Enable the SpaCy NER layer with an explicit model. Supports chaining.

    Mirrors :meth:`with_llm`. Pass ``language=`` (recommended; a list enables
    multilingual NER) or ``spacy_model=`` (explicit package name(s)).

    ::

        guard = Wardcat(salt="s").with_ner(language=Language.EN)
        guard = Wardcat(salt="s").with_ner(spacy_model=["en_core_web_sm", "de_core_news_sm"])

    :raises ConfigError: if neither ``language`` nor ``spacy_model`` is given.
    """
    if language is None and spacy_model is None:
        raise ConfigError(
            "with_ner() requires a model — pass language=... (e.g. Language.EN) "
            "or spacy_model=...; wardcat ships no default model."
        )
    if language is not None:
        models = self._resolve_language_models(language, spacy_size)
    else:
        models = [spacy_model] if isinstance(spacy_model, str) else list(spacy_model)  # type: ignore[arg-type]
        models = list(dict.fromkeys(models))
        if not models:
            raise ConfigError("spacy_model is empty — pass at least one model name.")
    self._config["spacy_models"] = models
    self._config["spacy_model"] = models[0]
    self._config["use_ner"] = True
    if auto_download:
        self._config["spacy_auto_download"] = True
    self._rebuild()
    return self

with_llm

with_llm(
    *,
    backend=OLLAMA,
    model="llama3.2",
    base_url=None,
    api_key="",
    timeout=60,
    allow_http=False,
    adjudicate=False,
    auto_pull=False,
    device_map="auto",
    load_in_8bit=False,
    load_in_4bit=False,
    dtype=None,
    language=None,
    circuit_failures=3,
    circuit_cooldown=30.0,
    max_concurrency=4,
)

Enable the on-prem LLM detector. Supports chaining, like :meth:with_ner.

.. warning:: Unlike with_ner(), this does not leave detection fully opt-in. The LLM layer carries its own default entity policy, so with_llm() switches on around fifteen entity types with the actions that policy names (PERSON → hash, EMAIL → warn, …) — not the action you pass to :meth:add_entity for something else. They are listed in a one-time warning at the first scan. Override one with add_entity(name, action, layers=["llm"]) or drop it with remove_entity(name); pass a YAML config_path to replace the policy wholesale.

The fluent way to configure the LLM layer (the constructor takes only config_path and salt) — keeps the LLM configuration in one place::

guard = (
    Wardcat(salt="s")
    .with_ner(language=Language.TR)
    .with_llm(backend=Backend.OLLAMA, model="llama3.2", adjudicate=True)
)

backend is the backend type (:class:~wardcat.Backend); the address goes to base_url.

Parameters:

Name Type Description Default
base_url str | None

the backend's address. Leave it unset to use the backend's own default — http://localhost:11434 for ollama and openai_compatible, http://localhost:8000/v1 for vllm. Only pass it to point at a non-default host/port; passing it here would otherwise override the backend-specific default (so selecting vllm without a base_url must still reach vLLM, not Ollama).

None
dtype str | None

weight dtype for the transformers backend, as a torch dtype name ("float16", "bfloat16", "float32"); an unknown name raises. Left unset the default is bfloat16, except on a pre-Ampere CUDA card, which has no bf16 support and gets float16. On Apple Silicon bfloat16 is emulated and fp16 ought to be faster, but loading as float16 with device_map="auto" on MPS segfaults on the supported torch/transformers versions — hence the argument rather than a different default. Ignored by the other backends, which do not load weights themselves.

None
language str | Language | None

selects a localized system prompt for :meth:is_sensitive (tr/de/fr; anything else uses the English, multilingual-aware prompt). It does not change the entity-detection prompt used by :meth:scan, which is multilingual by design.

None
circuit_failures int

consecutive backend failures after which the LLM layer is skipped without being called, so an outage does not make every scan wait the full timeout. 0 disables the breaker.

3
circuit_cooldown float

seconds the layer stays skipped before one call is tried again. While skipped, each scan carries a warning naming the open circuit; :meth:is_sensitive raises instead.

30.0
max_concurrency int

requests in flight at the backend at once, across :meth:scan_batch threads and async chunk fan-out. A local model server queues what it cannot run, so more only adds latency.

4
Source code in src/wardcat/guard.py
def with_llm(
    self,
    *,
    backend: str | Backend = Backend.OLLAMA,
    model: str = "llama3.2",
    base_url: str | None = None,
    api_key: str = "",
    timeout: int = 60,
    allow_http: bool = False,
    adjudicate: bool = False,
    auto_pull: bool = False,
    device_map: str = "auto",
    load_in_8bit: bool = False,
    load_in_4bit: bool = False,
    dtype: str | None = None,
    language: str | Language | None = None,
    circuit_failures: int = 3,
    circuit_cooldown: float = 30.0,
    max_concurrency: int = 4,
) -> Wardcat:
    """
    Enable the on-prem LLM detector. Supports chaining, like :meth:`with_ner`.

    .. warning::
        Unlike ``with_ner()``, this does **not** leave detection fully opt-in.
        The LLM layer carries its own default entity policy, so ``with_llm()``
        switches on around fifteen entity types with the actions that policy
        names (``PERSON`` → ``hash``, ``EMAIL`` → ``warn``, …) — not the action
        you pass to :meth:`add_entity` for something else. They are listed in a
        one-time warning at the first scan. Override one with
        ``add_entity(name, action, layers=["llm"])`` or drop it with
        ``remove_entity(name)``; pass a YAML ``config_path`` to replace the
        policy wholesale.

    The fluent way to configure the LLM layer (the constructor takes only
    ``config_path`` and ``salt``) — keeps the LLM configuration in one place::

        guard = (
            Wardcat(salt="s")
            .with_ner(language=Language.TR)
            .with_llm(backend=Backend.OLLAMA, model="llama3.2", adjudicate=True)
        )

    ``backend`` is the backend *type* (:class:`~wardcat.Backend`); the
    *address* goes to ``base_url``.

    :param base_url: the backend's address. Leave it unset to use the
        backend's own default — ``http://localhost:11434`` for ``ollama``
        and ``openai_compatible``, ``http://localhost:8000/v1`` for
        ``vllm``. Only pass it to point at a non-default host/port; passing
        it here would otherwise override the backend-specific default (so
        selecting ``vllm`` without a ``base_url`` must still reach vLLM,
        not Ollama).
    :param dtype: weight dtype for the ``transformers`` backend, as a torch
        dtype name (``"float16"``, ``"bfloat16"``, ``"float32"``); an
        unknown name raises. Left unset the default is ``bfloat16``, except
        on a pre-Ampere CUDA card, which has no bf16 support and gets
        ``float16``. On Apple Silicon ``bfloat16`` is emulated and fp16
        *ought* to be faster, but loading as ``float16`` with
        ``device_map="auto"`` on MPS segfaults on the supported
        torch/transformers versions — hence the argument rather than a
        different default. Ignored by the other backends, which do not load
        weights themselves.
    :param language: selects a localized system prompt for :meth:`is_sensitive`
        (``tr``/``de``/``fr``; anything else uses the English, multilingual-aware
        prompt). It does not change the entity-detection prompt used by
        :meth:`scan`, which is multilingual by design.
    :param circuit_failures: consecutive backend failures after which the
        LLM layer is skipped without being called, so an outage does not make
        every scan wait the full ``timeout``. ``0`` disables the breaker.
    :param circuit_cooldown: seconds the layer stays skipped before one call
        is tried again. While skipped, each scan carries a warning naming
        the open circuit; :meth:`is_sensitive` raises instead.
    :param max_concurrency: requests in flight at the backend at once,
        across :meth:`scan_batch` threads and async chunk fan-out. A local
        model server queues what it cannot run, so more only adds latency.
    """
    lang_code = language.value if isinstance(language, Language) else language
    llm_cfg = self._config.setdefault("llm_detector", {})
    llm_cfg.update(
        {
            "enabled": True,
            "backend": backend.value if isinstance(backend, Backend) else backend,
            "model": model,
            "api_key": api_key,
            "timeout": timeout,
            "allow_http": allow_http,
            "adjudicate": adjudicate,
            "auto_pull": auto_pull,
            "device_map": device_map,
            "load_in_8bit": load_in_8bit,
            "load_in_4bit": load_in_4bit,
            "dtype": dtype,
            "language": lang_code,
            "circuit_failures": circuit_failures,
            "circuit_cooldown": circuit_cooldown,
            "max_concurrency": max_concurrency,
        }
    )
    # Only pin base_url when the caller gave one; otherwise leave it out so
    # each backend factory applies its own default (Ollama 11434 vs vLLM
    # 8000/v1). A prior with_llm() call's base_url is cleared here too.
    if base_url is not None:
        llm_cfg["base_url"] = base_url
    else:
        llm_cfg.pop("base_url", None)
    self._rebuild()
    return self

with_phone_regions

with_phone_regions(*regions)

Detect national phone formats for regions via libphonenumber.

The built-in PHONE pattern is precision-first and covers Turkish, French and German national formats plus E.164 — a number written the way it is written in Manchester or Madrid falls through it. Naming the regions you actually serve swaps in libphonenumber for those formats::

guard = Wardcat(salt=s).add_entity(Entity.PHONE).with_phone_regions("GB", "ES")

Regions are CLDR two-letter codes. Needs the extra: pip install 'wardcat[phone]' — without it the built-in pattern is used and a warning is logged. Call with no arguments to go back to the pattern.

Matches are reported at 0.90 confidence, not the 0.97 of the built-in pattern: libphonenumber validates against each region's numbering plan, which is far stronger than a bare digit run but weaker than a checksum, and every extra region widens what counts as a number. Add the regions you serve, not every region there is.

Source code in src/wardcat/guard.py
def with_phone_regions(self, *regions: str) -> Wardcat:
    """Detect national phone formats for *regions* via libphonenumber.

    The built-in ``PHONE`` pattern is precision-first and covers Turkish,
    French and German national formats plus E.164 — a number written the way
    it is written in Manchester or Madrid falls through it. Naming the regions
    you actually serve swaps in libphonenumber for those formats::

        guard = Wardcat(salt=s).add_entity(Entity.PHONE).with_phone_regions("GB", "ES")

    Regions are CLDR two-letter codes. Needs the extra: ``pip install
    'wardcat[phone]'`` — without it the built-in pattern is used and a warning
    is logged. Call with no arguments to go back to the pattern.

    Matches are reported at 0.90 confidence, not the 0.97 of the built-in
    pattern: libphonenumber validates against each region's numbering plan,
    which is far stronger than a bare digit run but weaker than a checksum, and
    every extra region widens what counts as a number. Add the regions you
    serve, not every region there is.
    """
    codes = [r.strip().upper() for r in regions if r and r.strip()]
    self._config["phone_regions"] = codes
    self._rebuild()
    return self

with_min_confidence

with_min_confidence(minimum)

Set the confidence floor: spans scoring below minimum are dropped.

Every detection carries a confidence, tiered by how strong the evidence is — a checksum-verified card is 1.0, a distinctive format such as an email is 0.97, a model layer is 0.85, a keyword-heuristic address is 0.90, and a checksum whose own odds are weak (the ABA mod-10, the NHS mod-11, the IMEI Luhn) with no supporting keyword nearby is 0.70.

The default floor is 0.8, which sits between that last tier and everything else: those uncued matches are found but not acted on. Lower it to trade precision for recall::

guard.with_min_confidence(0.6)   # act on uncued checksum matches too

Set it to 0 to act on everything a layer reports.

Raises:

Type Description
ConfigError

if minimum is not a number between 0 and 1.

Source code in src/wardcat/guard.py
def with_min_confidence(self, minimum: float) -> Wardcat:
    """Set the confidence floor: spans scoring below *minimum* are dropped.

    Every detection carries a confidence, tiered by how strong the evidence
    is — a checksum-verified card is 1.0, a distinctive format such as an
    email is 0.97, a model layer is 0.85, a keyword-heuristic address is
    0.90, and a checksum whose own odds are weak (the ABA mod-10, the NHS
    mod-11, the IMEI Luhn) with no supporting keyword nearby is 0.70.

    The default floor is ``0.8``, which sits between that last tier and
    everything else: those uncued matches are found but not acted on. Lower
    it to trade precision for recall::

        guard.with_min_confidence(0.6)   # act on uncued checksum matches too

    Set it to ``0`` to act on everything a layer reports.

    :raises ConfigError: if *minimum* is not a number between 0 and 1.
    """
    from wardcat.config.loader import _validate_min_confidence

    _validate_min_confidence(minimum)
    self._config["min_confidence"] = float(minimum)
    self._rebuild()
    return self

set_salt

set_salt(salt)

Update the hash salt.

Source code in src/wardcat/guard.py
def set_salt(self, salt: str) -> Wardcat:
    """Update the hash salt."""
    self._config["salt"] = salt
    self._rebuild()
    return self

add_allowlist

add_allowlist(values)

Add exact values that should never be flagged as PII.

Supports method chaining::

guard.add_allowlist(["no-reply@company.com", "192.168.1.1"])

Parameters:

Name Type Description Default
values list[str]

List of exact string values to exempt from detection.

required
Source code in src/wardcat/guard.py
def add_allowlist(self, values: list[str]) -> Wardcat:
    """Add exact values that should never be flagged as PII.

    Supports method chaining::

        guard.add_allowlist(["no-reply@company.com", "192.168.1.1"])

    :param values: List of exact string values to exempt from detection.
    """
    existing: list[str] = self._config.setdefault("allowlist", [])
    for v in values:
        if v not in existing:
            existing.append(v)
    self._rebuild()
    return self

add_denylist

add_denylist(entries)

Add values that should always be flagged as PII.

Each entry has an entity_type and either an exact value or a regex pattern. The action applied is taken from the entity's config (same as regular detections). Supports method chaining::

guard.add_denylist([
    {"value": "John Smith",    "entity_type": "PERSON"},
    {"pattern": r"\bPRJ-\d{4}\b", "entity_type": "CUSTOM_SECRET"},
])

Entries are validated exactly as a YAML denylist is, and all of them before any is added: a pattern that is not valid regex, or that backtracks catastrophically, is refused — a match cannot be interrupted once a scan has started it.

Parameters:

Name Type Description Default
entries list[dict[str, str]]

List of dicts with entity_type and value or pattern.

required

Raises:

Type Description
ConfigError

if any entry is invalid.

Source code in src/wardcat/guard.py
def add_denylist(self, entries: list[dict[str, str]]) -> Wardcat:
    """Add values that should always be flagged as PII.

    Each entry has an ``entity_type`` and either an exact ``value`` or a regex
    ``pattern``. The action applied is taken from the entity's config (same as
    regular detections).  Supports method chaining::

        guard.add_denylist([
            {"value": "John Smith",    "entity_type": "PERSON"},
            {"pattern": r"\\bPRJ-\\d{4}\\b", "entity_type": "CUSTOM_SECRET"},
        ])

    Entries are validated exactly as a YAML ``denylist`` is, and all of them
    before any is added: a pattern that is not valid regex, or that backtracks
    catastrophically, is refused — a match cannot be interrupted once a scan
    has started it.

    :param entries: List of dicts with ``entity_type`` and ``value`` or ``pattern``.
    :raises ConfigError: if any entry is invalid.
    """
    _validate_denylist(entries)
    self._config.setdefault("denylist", []).extend(entries)
    self._rebuild()
    return self

supported_presets staticmethod

supported_presets()

The names :meth:with_preset accepts.

Source code in src/wardcat/guard.py
@staticmethod
def supported_presets() -> tuple[str, ...]:
    """The names :meth:`with_preset` accepts."""
    from wardcat.presets import supported_presets

    return supported_presets()

with_preset

with_preset(name)

Enable the entities of a starting policy, with the actions it names.

A preset is an entity → action mapping modelled on a data-protection regime ("kvkk", "gdpr", "pci_dss", "hipaa_lite", "secrets_only"). It switches no layer on: names need :meth:with_ner or :meth:with_llm, special-category data needs :meth:with_llm, and an entity left without its layer is reported as uncovered at the first scan, as always. Adjust afterwards with :meth:change_entity_action and :meth:remove_entity. YAML: preset: kvkk, with the file's own entities taking precedence.

See the presets guide for what each one enables and, as importantly, what it does not cover.

Raises:

Type Description
ConfigError

for an unknown preset name.

Source code in src/wardcat/guard.py
def with_preset(self, name: str) -> Wardcat:
    """Enable the entities of a starting policy, with the actions it names.

    A preset is an entity → action mapping modelled on a data-protection
    regime (``"kvkk"``, ``"gdpr"``, ``"pci_dss"``, ``"hipaa_lite"``,
    ``"secrets_only"``). It switches no layer on: names need
    :meth:`with_ner` or :meth:`with_llm`, special-category data needs
    :meth:`with_llm`, and an entity left without its layer is reported as
    uncovered at the first scan, as always. Adjust afterwards with
    :meth:`change_entity_action` and :meth:`remove_entity`. YAML: ``preset: kvkk``,
    with the file's own ``entities`` taking precedence.

    See the presets guide for what each one enables and, as importantly,
    what it does not cover.

    :raises ConfigError: for an unknown preset name.
    """
    self._apply_preset(name, keep_existing=False)
    self._rebuild()
    return self

with_locale

with_locale(locale)

Choose the language the surrogate action draws its stand-ins from.

"en", "tr", "de" or "fr"; the default is "en". It is deliberately not inferred from the NER or LLM language — a multilingual guard has no single one. YAML: locale: tr.

Raises:

Type Description
ConfigError

for a locale without name pools.

Source code in src/wardcat/guard.py
def with_locale(self, locale: str | Language) -> Wardcat:
    """Choose the language the ``surrogate`` action draws its stand-ins from.

    ``"en"``, ``"tr"``, ``"de"`` or ``"fr"``; the default is ``"en"``. It is
    deliberately not inferred from the NER or LLM language — a multilingual
    guard has no single one. YAML: ``locale: tr``.

    :raises ConfigError: for a locale without name pools.
    """
    from wardcat.surrogates import SUPPORTED_LOCALES

    code = (locale.value if isinstance(locale, Language) else str(locale)).lower()
    if code not in SUPPORTED_LOCALES:
        raise ConfigError(
            f"Unsupported surrogate locale {code!r}. Supported: {', '.join(SUPPORTED_LOCALES)}."
        )
    self._config["locale"] = code
    self._rebuild()
    return self

with_strict

with_strict(enabled=True)

Refuse any scan that covers less than was configured.

By default a layer that cannot run — a SpaCy model that failed to load, an LLM backend that is down — is reported on :attr:ScanResult.warnings and the partial result is returned, which is right for a chat guard that must answer. A pipeline that indexes documents wants the opposite: better no result than a document stored with names in it. With strict on, such a condition raises :class:~wardcat.DegradedScanError — at build time for problems known then, at scan time for the rest, and from :meth:scan_batch too, which otherwise turns errors into scan_error entries.

YAML: strict: true.

Source code in src/wardcat/guard.py
def with_strict(self, enabled: bool = True) -> Wardcat:
    """Refuse any scan that covers less than was configured.

    By default a layer that cannot run — a SpaCy model that failed to load,
    an LLM backend that is down — is reported on :attr:`ScanResult.warnings`
    and the partial result is returned, which is right for a chat guard
    that must answer. A pipeline that indexes documents wants the opposite:
    better no result than a document stored with names in it. With strict
    on, such a condition raises :class:`~wardcat.DegradedScanError` — at
    build time for problems known then, at scan time for the rest, and
    from :meth:`scan_batch` too, which otherwise turns errors into
    ``scan_error`` entries.

    YAML: ``strict: true``.
    """
    self._config["strict"] = bool(enabled)
    self._rebuild()
    return self

with_propagation

with_propagation(*, enabled=True, min_length=3)

Redact every occurrence of a value once any layer detects it.

Model-based layers (NER/LLM) often report a repeated value only once, which would leave the other occurrences unredacted. With propagation on, a value detected anywhere is anonymized at every whole-token occurrence in the text — using that value's entity type and action. Deterministic regex spans still win overlaps, so a propagated match never displaces a checksum-validated one. Chainable::

guard = Wardcat(salt="s").with_ner().add_entity("PERSON").with_propagation()

It can over-redact (e.g. a short common name), so it is off by default and only exact, token-bounded matches at least min_length chars long are propagated.

Parameters:

Name Type Description Default
enabled bool

turn propagation on (default) or off.

True
min_length int

skip values shorter than this many characters.

3
Source code in src/wardcat/guard.py
def with_propagation(self, *, enabled: bool = True, min_length: int = 3) -> Wardcat:
    """Redact **every** occurrence of a value once any layer detects it.

    Model-based layers (NER/LLM) often report a repeated value only
    once, which would leave the other occurrences unredacted. With
    propagation on, a value detected anywhere is anonymized at every
    whole-token occurrence in the text — using that value's entity type and
    action. Deterministic regex spans still win overlaps, so a propagated
    match never displaces a checksum-validated one. Chainable::

        guard = Wardcat(salt="s").with_ner().add_entity("PERSON").with_propagation()

    It can over-redact (e.g. a short common name), so it is **off by default**
    and only exact, token-bounded matches at least ``min_length`` chars long
    are propagated.

    :param enabled:    turn propagation on (default) or off.
    :param min_length: skip values shorter than this many characters.
    """
    self._config["propagate_matches"] = enabled
    self._config["propagate_min_length"] = min_length
    self._rebuild()
    return self