Skip to content

Results & constants

Results

wardcat.ScanResult dataclass

ScanResult(
    original_text,
    sanitized_text,
    violations=list(),
    scan_error=None,
    warnings=list(),
    context_id="",
    _salt="",
)

Result of a single guard.scan() call.

.. warning:: The original_text and violations[].original fields contain raw PII, as do :attr:token_map and anything :meth:restore returns. When writing this object to logs, databases, or API responses, use only sanitized_text. Use the :meth:redacted method to obtain a dict that contains no PII.

original_text instance-attribute

original_text

Unmodified original input. Contains raw PII — do not expose externally.

sanitized_text instance-attribute

sanitized_text

Output text with PII masked/reported.

violations class-attribute instance-attribute

violations = field(default_factory=list)

List of all detected violations. The original fields contain raw PII.

scan_error class-attribute instance-attribute

scan_error = None

Set when this item failed during :meth:~wardcat.Wardcat.scan_batch. The original text is returned unchanged. Non-None means the scan result is incomplete — callers should not treat the text as clean.

warnings class-attribute instance-attribute

warnings = field(default_factory=list)

Non-fatal issues during this scan — e.g. a detector layer that could not run (LLM backend unreachable). The scan still returns results from the other layers, but a non-empty list means detection was degraded: some PII may be missed. Empty when every configured layer ran.

context_id class-attribute instance-attribute

context_id = ''

Identifies this scan's anonymization pass. It is stamped into every reversible (:attr:Action.TOKENIZE) placeholder — [EMAIL_1_9f3a2c8b71d4] — so no two scans ever produce the same token and :meth:restore can tell another scan's placeholder from its own. Empty when nothing stamped one.

is_clean property

is_clean

True if no PII was detected.

token_map property

token_map

Placeholder → original value for every violation that replaced something.

The vault behind :meth:restore, exposed for callers that need to hand the mapping to another process (a queue worker restoring a reply later, say)::

vault = result.token_map          # {"[EMAIL_1]": "ali@example.com"}

Meaningful for reversible placeholders — see :attr:Action.TOKENIZE; with redact or mask several values can share one placeholder and the map keeps only the last. warn violations are absent (nothing was replaced).

.. warning:: Raw PII. Never log or persist this without protecting it like the original text.

redacted

redacted()

Return a safe :class:RedactedResult dict with no PII.

Excludes the original_text and violations[].original fields. Use this method for logs, API responses, or database records::

result = guard.scan(text)
log.info("scan result: %s", result.redacted())

Returns: A :class:RedactedResult (TypedDict) containing sanitized_text, is_clean, scan_error, and violation metadata (entity_type, start, end, action, replacement, confidence). Raw PII is not included.

Source code in src/wardcat/core/models.py
def redacted(self) -> RedactedResult:
    """Return a safe :class:`RedactedResult` dict with no PII.

    Excludes the ``original_text`` and ``violations[].original`` fields.
    Use this method for logs, API responses, or database records::

        result = guard.scan(text)
        log.info("scan result: %s", result.redacted())

    Returns:
        A :class:`RedactedResult` (``TypedDict``) containing ``sanitized_text``,
        ``is_clean``, ``scan_error``, and violation metadata (entity_type,
        start, end, action, replacement, confidence). Raw PII is not included.
    """
    return {
        "is_clean": self.is_clean,
        "sanitized_text": self.sanitized_text,
        "scan_error": self.scan_error,
        "warnings": list(self.warnings),
        "violations": [
            {
                "entity_type": v.entity_type,
                "start": v.start,
                "end": v.end,
                "action": v.action,
                "replacement": v.replacement,
                "confidence": v.confidence,
            }
            for v in self.violations
        ],
    }

restore

restore(text=None, *, strict=False, also=())

Put the real values back — the reverse of the anonymization stage.

The round trip an LLM call needs: scan the prompt, send the anonymized text out, then restore the answer that comes back::

result = guard.scan(prompt)
answer = call_llm(result.sanitized_text)   # never sees the real values
print(result.restore(answer))              # answer + its source list

Printing the returned :class:~wardcat.core.restore.RestoredText appends an ordered source list naming, per placeholder, which filter fired and what it stood for; use .text for the bare restored text and .substitutions for the same information as data.

Restoring is reliable when the values were replaced with :attr:Action.TOKENIZE (or hash): each distinct value has its own placeholder. With redact/mask two values can collapse onto the same placeholder — those are reported in .unrestored as ambiguous and left in the text rather than guessed. Use :meth:reapply to derive a reversible view of a scan that used another action::

reversible = result.reapply(Action.TOKENIZE)

Placeholders carry this scan's :attr:context_id, so a token produced by a different scan cannot be matched by accident — restoring one request's answer against another request's result would otherwise substitute the wrong person's values. Such tokens are reported as foreign, left in the text, and make is_complete False; strict=True raises :class:~wardcat.exceptions.ContextMismatch instead. In a multi-turn exchange an answer may legitimately carry an earlier scan's placeholders — pass those results in also and they are restored too::

turn2.restore(answer, also=[turn1], strict=True)

Parameters:

Name Type Description Default
text str | None

the text to restore; defaults to :attr:sanitized_text, which round-trips back to the original input.

None
strict bool

raise :class:~wardcat.exceptions.ContextMismatch when the text carries a placeholder no known result produced.

False
also Iterable[ScanResult]

other results whose placeholders are legitimate here.

()

Returns:

Type Description
RestoredText

a :class:~wardcat.core.restore.RestoredText. Contains raw PII.

Source code in src/wardcat/core/models.py
def restore(
    self,
    text: str | None = None,
    *,
    strict: bool = False,
    also: Iterable[ScanResult] = (),
) -> RestoredText:
    """Put the real values back — the reverse of the anonymization stage.

    The round trip an LLM call needs: scan the prompt, send the anonymized
    text out, then restore the answer that comes back::

        result = guard.scan(prompt)
        answer = call_llm(result.sanitized_text)   # never sees the real values
        print(result.restore(answer))              # answer + its source list

    Printing the returned :class:`~wardcat.core.restore.RestoredText` appends an
    ordered source list naming, per placeholder, which filter fired and what it
    stood for; use ``.text`` for the bare restored text and ``.substitutions``
    for the same information as data.

    Restoring is reliable when the values were replaced with
    :attr:`Action.TOKENIZE` (or ``hash``): each distinct value has its own
    placeholder. With ``redact``/``mask`` two values can collapse onto the same
    placeholder — those are reported in ``.unrestored`` as ``ambiguous`` and
    left in the text rather than guessed. Use :meth:`reapply` to derive a
    reversible view of a scan that used another action::

        reversible = result.reapply(Action.TOKENIZE)

    Placeholders carry this scan's :attr:`context_id`, so a token produced by a
    *different* scan cannot be matched by accident — restoring one request's
    answer against another request's result would otherwise substitute the
    wrong person's values. Such tokens are reported as ``foreign``, left in the
    text, and make ``is_complete`` ``False``; ``strict=True`` raises
    :class:`~wardcat.exceptions.ContextMismatch` instead. In a multi-turn
    exchange an answer may legitimately carry an earlier scan's placeholders —
    pass those results in ``also`` and they are restored too::

        turn2.restore(answer, also=[turn1], strict=True)

    :param text: the text to restore; defaults to :attr:`sanitized_text`, which
                 round-trips back to the original input.
    :param strict: raise :class:`~wardcat.exceptions.ContextMismatch` when the
                 text carries a placeholder no known result produced.
    :param also: other results whose placeholders are legitimate here.
    :returns: a :class:`~wardcat.core.restore.RestoredText`. **Contains raw PII.**
    """
    from wardcat.core.restore import restore_text

    violations = list(self.violations)
    for other in also:
        violations.extend(other.violations)
    return restore_text(
        self.sanitized_text if text is None else text, violations, strict=strict
    )

reapply

reapply(action, entities=None)

Re-anonymize this already-detected result under a different action.

scan() does the expensive detection (regex + NER + LLM) once; this reuses those spans and re-runs only the cheap anonymization step, so you can derive several outputs from a single scan without re-scanning or reloading any model::

result = guard.scan(text)
masked = result.reapply(Action.MASK).sanitized_text
hashed = result.reapply(Action.HASH).sanitized_text
# re-anonymize only a subset of the detected types:
emails = result.reapply(Action.HASH, entities=["EMAIL"])

The salt from the originating :class:~wardcat.Wardcat is reused, so hash output matches what the guard would have produced. Types passed in entities that were not detected are simply absent (nothing to anonymize). Concurrency-safe: nothing shared is mutated.

Parameters:

Name Type Description Default
action Action | str

the action to apply (an :class:Action or its name).

required
entities Iterable[str] | None

optional subset of entity types to re-anonymize; None applies action to every detected violation.

None

Returns:

Type Description
ScanResult

a new :class:ScanResult under the requested action.

Raises:

Type Description
ConfigError

if action is not a registered action.

Source code in src/wardcat/core/models.py
def reapply(self, action: Action | str, entities: Iterable[str] | None = None) -> ScanResult:
    """Re-anonymize this already-detected result under a different ``action``.

    ``scan()`` does the expensive detection (regex + NER + LLM) once; this
    reuses those spans and re-runs only the cheap anonymization step, so you
    can derive several outputs from a single scan without re-scanning or
    reloading any model::

        result = guard.scan(text)
        masked = result.reapply(Action.MASK).sanitized_text
        hashed = result.reapply(Action.HASH).sanitized_text
        # re-anonymize only a subset of the detected types:
        emails = result.reapply(Action.HASH, entities=["EMAIL"])

    The salt from the originating :class:`~wardcat.Wardcat` is reused, so
    ``hash`` output matches what the guard would have produced. Types passed
    in ``entities`` that were not detected are simply absent (nothing to
    anonymize). Concurrency-safe: nothing shared is mutated.

    :param action:   the action to apply (an :class:`Action` or its name).
    :param entities: optional subset of entity types to re-anonymize;
                     ``None`` applies ``action`` to every detected violation.
    :returns:        a new :class:`ScanResult` under the requested action.
    :raises ConfigError: if ``action`` is not a registered action.
    """
    from wardcat.core.actions import new_context_id, registered_actions
    from wardcat.core.anonymizer import Anonymizer
    from wardcat.detectors.base import DetectedSpan
    from wardcat.exceptions import ConfigError

    name = action.value if isinstance(action, Action) else str(action)
    if name not in registered_actions():
        raise ConfigError(
            f"unknown action {name!r}; choose one of {sorted(registered_actions())}"
        )

    keep: set[str] | None = None
    if entities is not None:
        keep = {e.value if isinstance(e, Enum) else str(e) for e in entities}

    violations = self.violations
    if keep is not None:
        violations = [v for v in violations if v.entity_type in keep]

    spans = [
        DetectedSpan(v.entity_type, v.original, v.start, v.end, v.confidence)
        for v in violations
    ]
    config = {v.entity_type: {"action": name} for v in violations}
    # A new pass produces new placeholders, so it gets its own context id —
    # the derived result must not answer for the one it came from.
    context_id = new_context_id()
    sanitized, new_violations = Anonymizer(config, salt=self._salt).apply(
        self.original_text, spans, context_id=context_id
    )
    return ScanResult(
        original_text=self.original_text,
        sanitized_text=sanitized,
        violations=new_violations,
        scan_error=self.scan_error,
        warnings=list(self.warnings),
        context_id=context_id,
        _salt=self._salt,
    )

wardcat.Violation dataclass

Violation(
    entity_type,
    original,
    start,
    end,
    action,
    replacement=None,
    confidence=1.0,
)

A single PII violation detected in the text.

entity_type instance-attribute

entity_type

E.g. "EMAIL", "CREDIT_CARD", "PERSON".

original instance-attribute

original

Raw value from the original text.

start instance-attribute

start

Start index in the original text.

end instance-attribute

end

End index in the original text.

action instance-attribute

action

Name of the action that was applied ("warn" / "hash" / "redact" / "mask", or a custom registered action). Compares equal to the matching :class:Action constant (v.action == Action.HASH).

replacement class-attribute instance-attribute

replacement = None

What the original was replaced with — the hash/redact/mask output, or the reversible tokenize placeholder. None for warn, which replaces nothing. Together with :attr:original this is what :meth:ScanResult.restore reverses.

confidence class-attribute instance-attribute

confidence = 1.0

Detection confidence in [0.0, 1.0], tiered by how certain the match is:

  • Checksum-validated regex (TC_ID, IBAN, CREDIT_CARD): 1.0 — mathematically verified.
  • High-precision structural regex (email, JWT, IP, secrets, …): 0.97.
  • Fuzzy regex (ADDRESS, VEHICLE_PLATE): 0.90 — a distinctive but ambiguous, heuristic match.
  • NER (SpaCy) / LLM: 0.85 — model-based.

The engine resolves overlaps highest-confidence-first, so a regex span always beats a model guess, and in adjudication every regex span is protected while the model layers may be overridden. Use this field to threshold::

certain = [v for v in result.violations if v.confidence >= 1.0]  # checksum only

wardcat.RedactedResult

Bases: TypedDict

The PII-free dict returned by :meth:ScanResult.redacted (safe to log).

wardcat.RestoredText dataclass

RestoredText(text, substitutions=list(), unrestored=list())

Result of :meth:~wardcat.ScanResult.restore — the text plus its sources.

str(restored) is the text with the source list appended, which is usually what you want to show::

answer = call_llm(result.sanitized_text)
print(result.restore(answer))

.. warning:: Contains raw PII in text and in every substitutions[].original.

text instance-attribute

text

The text with every unambiguous placeholder replaced by its original value.

substitutions class-attribute instance-attribute

substitutions = field(default_factory=list)

What was put back, ordered by first appearance in :attr:text.

unrestored class-attribute instance-attribute

unrestored = field(default_factory=list)

Detected values that were not put back, with the reason for each.

is_complete property

is_complete

True when no placeholder was left sitting in the text.

False when something was skipped for being ambiguous or was foreign — in both cases a placeholder is still there for a reader to trip over. A not-present entry does not make a restore incomplete: an answer that never mentions a placeholder has nothing to put back.

sources_block

sources_block(*, title=_SOURCES_TITLE, notes=True)

Render the ordered source list — which filter fired, and what it hid.

::

[1] [PERSON_1] → Ali Veli (PERSON · tokenize · x2 · confidence 0.85)
[2] [EMAIL_1] → ali@example.com (EMAIL · tokenize · confidence 0.97)

Returns "" when this text carries nothing to cite — nothing was put back and no placeholder was left in it — so appending the block to a clean answer adds nothing. A value that was masked but never mentioned in the text is not, by itself, something to cite.

Parameters:

Name Type Description Default
title str

heading for the block.

_SOURCES_TITLE
notes bool

also summarize the values that were not put back.

True
Source code in src/wardcat/core/restore.py
def sources_block(self, *, title: str = _SOURCES_TITLE, notes: bool = True) -> str:
    """Render the ordered source list — which filter fired, and what it hid.

    ::

        [1] [PERSON_1] → Ali Veli (PERSON · tokenize · x2 · confidence 0.85)
        [2] [EMAIL_1] → ali@example.com (EMAIL · tokenize · confidence 0.97)

    Returns ``""`` when this text carries nothing to cite — nothing was put
    back and no placeholder was left in it — so appending the block to a
    clean answer adds nothing. A value that was masked but never mentioned in
    the text is not, by itself, something to cite.

    :param title: heading for the block.
    :param notes: also summarize the values that were *not* put back.
    """
    if not self.substitutions and self.is_complete:
        return ""

    lines = [f"--- {title} ---"]
    for sub in self.substitutions:
        facts = [sub.entity_type, sub.action]
        if sub.occurrences > 1:
            facts.append(f"x{sub.occurrences}")
        facts.append(f"confidence {sub.confidence:.2f}")
        lines.append(f"[{sub.index}] {sub.placeholder}{sub.original} ({' · '.join(facts)})")

    if notes:
        lines.extend(self._notes())
    return "\n".join(lines) if len(lines) > 1 else ""

with_sources

with_sources(*, title=_SOURCES_TITLE, notes=True)

:attr:text with :meth:sources_block appended below it.

Source code in src/wardcat/core/restore.py
def with_sources(self, *, title: str = _SOURCES_TITLE, notes: bool = True) -> str:
    """:attr:`text` with :meth:`sources_block` appended below it."""
    block = self.sources_block(title=title, notes=notes)
    return f"{self.text}\n\n{block}" if block else self.text

wardcat.Substitution dataclass

Substitution(
    index,
    entity_type,
    action,
    placeholder,
    original,
    occurrences,
    confidence,
)

One placeholder that was swapped back for its original value.

.. warning:: original is raw PII — the whole point of restoring — so a :class:RestoredText is as sensitive as the input it came from.

index instance-attribute

index

1-based position in the source list, ordered by first appearance in the text.

entity_type instance-attribute

entity_type

E.g. "EMAIL", "PERSON".

action instance-attribute

action

Action that produced the placeholder ("tokenize", "hash", …).

placeholder instance-attribute

placeholder

The text that stood in for the value ("[PERSON_1]").

original instance-attribute

original

The value that was put back. Raw PII.

occurrences instance-attribute

occurrences

How many times the placeholder appeared in the restored text.

confidence instance-attribute

confidence

Detection confidence of the underlying violation.

wardcat.UnrestoredValue dataclass

UnrestoredValue(entity_type, action, placeholder, reason)

A detected value that was not put back, and why.

Carries no raw value: not-present is the common, boring case (the answer simply never mentioned that placeholder), and the caller can look the value up in violations when it matters.

placeholder instance-attribute

placeholder

None for report-only actions such as warn, which replace nothing.

reason instance-attribute

reason

"not-present" — the placeholder does not occur in the text (an empty replacement, which a custom action may return, occurs nowhere locatable); "ambiguous" — it stood for more than one distinct value, so restoring it would be a guess; "not-replaced" — the action left the original text in place (warn), so there is nothing to reverse; "foreign" — the text carries a placeholder this result never produced (another scan's, or one the model invented), so it was left alone.

Exceptions

Everything wardcat raises derives from WardcatError, so one except catches the lot. ConfigError and ModelDownloadError also subclass the built-in they replaced (ValueError / RuntimeError), so existing handlers keep working.

wardcat.WardcatError

Bases: Exception

Base class for every error raised by wardcat.

wardcat.ContextMismatch

ContextMismatch(placeholders)

Bases: WardcatError

A text carries a reversible placeholder that belongs to a different scan.

Raised by ScanResult.restore(..., strict=True). Every scan stamps its own context id into the placeholders it produces, so a token from another scan can be recognized rather than silently matched — restoring one request's answer against another request's result would otherwise substitute the wrong person's values. Nothing is substituted for the foreign tokens either way; strict only decides whether that is an error or a report.

placeholders lists the foreign tokens found.

Source code in src/wardcat/exceptions.py
def __init__(self, placeholders: list[str]) -> None:
    self.placeholders = placeholders
    super().__init__(
        f"{len(placeholders)} placeholder(s) in this text belong to a different "
        f"scan and were left in place: {', '.join(sorted(placeholders))}. Restore "
        "with the ScanResult that produced them, or pass them in `also=[...]`."
    )

wardcat.ConfigError

Bases: WardcatError, ValueError

Invalid configuration (bad action, backend, pattern, entity spec, …).

Subclasses :class:ValueError for backward compatibility.

wardcat.ModelDownloadError

Bases: WardcatError, RuntimeError

A SpaCy/LLM model could not be downloaded or is incompatible.

Subclasses :class:RuntimeError for backward compatibility.

wardcat.UnsupportedLanguageError

Bases: ConfigError

The requested NER language (or size tier) has no compatible model.

Constants

wardcat.Entity

Bases: str, Enum

Known entity types, as constants — for autocomplete and typo-proofing.

Use these instead of bare strings when configuring the guard::

from wardcat import Entity, Action

guard.add_entity(Entity.CREDIT_CARD, action=Action.HASH)

Each member is its string value (Entity.EMAIL == "EMAIL"), so it can be used anywhere a plain entity-type string is accepted. Note: because this is a (str, Enum), use .value to get the canonical string — str(Entity.EMAIL) returns "Entity.EMAIL", not "EMAIL".

The special member :attr:Entity.ALL is a sentinel, not a real entity type: passing it to :meth:~wardcat.Wardcat.add_entity / :meth:~wardcat.Wardcat.remove_entity enables/disables every known entity in one call. It is excluded from :data:KNOWN_ENTITY_TYPES. Entity.All is a deprecated alias of Entity.ALL kept for backward compatibility.

ALL class-attribute instance-attribute

ALL = '__ALL__'

Sentinel selecting every known entity type (not a real entity).

All class-attribute instance-attribute

All = '__ALL__'

Deprecated PascalCase alias of :attr:ALL.

wardcat.Action

Bases: str, Enum

Action to apply to detected PII.

WARN class-attribute instance-attribute

WARN = 'warn'

Leave the text as-is, report only as a violation.

HASH class-attribute instance-attribute

HASH = 'hash'

Mask with SHA-256 + salt: [ENTITY_TYPE:abcd1234].

REDACT class-attribute instance-attribute

REDACT = 'redact'

Replace with a plain label: [ENTITY_TYPE] — no hash, no original value.

MASK class-attribute instance-attribute

MASK = 'mask'

Partially obscure the value, entity-aware. Most types reveal only the last few characters — e.g. CREDIT_CARD************1111, EMAILu***@example.com, SSN***-**-6789. Types without a specific rule fall back to first 2 + * + last 2 (abcdefab**ef); values shorter than 4 characters are fully replaced with *. See wardcat.core.actions._mask_value for the per-type rules.

TOKENIZE class-attribute instance-attribute

TOKENIZE = 'tokenize'

Reversible masking: replace with a numbered placeholder — [PERSON_1], [EMAIL_1], [PERSON_2] — numbered per entity type in order of appearance, with one token per distinct value (a name repeated stays one referent). The mapping lives on the :class:ScanResult, so :meth:ScanResult.restore can put the real values back into whatever comes out the other side — an LLM's answer, typically. Unlike hash/redact/mask this keeps the originals in memory: the result object is as sensitive as the input.

wardcat.Backend

Bases: str, Enum

LLM backend types, as constants — for typo-proof selection.

Pass these to :meth:Wardcat.with_llm instead of bare strings::

from wardcat import Wardcat, Backend

Wardcat(salt="s").with_llm(backend=Backend.OPENAI_COMPATIBLE, model="...")

Each member is its string value (Backend.OLLAMA == "ollama"), so the plain string form is still accepted.

OLLAMA class-attribute instance-attribute

OLLAMA = 'ollama'

Local Ollama service (supports model download).

OPENAI_COMPATIBLE class-attribute instance-attribute

OPENAI_COMPATIBLE = 'openai_compatible'

OpenAI-compatible HTTP API — LM Studio, LocalAI, LiteLLM, …

VLLM class-attribute instance-attribute

VLLM = 'vllm'

vLLM server (OpenAI-compatible API) — native chat, vLLM defaults.

TRANSFORMERS class-attribute instance-attribute

TRANSFORMERS = 'transformers'

In-process HuggingFace Transformers (no HTTP; loads the model locally).

wardcat.Language

Bases: str, Enum

Supported NER languages, as constants — for documented, typo-proof selection.

Pass these to Wardcat().with_ner(language=...) instead of bare ISO codes::

from wardcat import Wardcat, Language

Wardcat().with_ner(language=Language.EN)                 # one language
Wardcat().with_ner(language=[Language.EN, Language.DE])  # multilingual NER

Each member is its ISO 639-1 code (Language.EN == "en"), so the plain string form is still accepted.