Results & constants¶
Results¶
wardcat.ScanResult
dataclass
¶
ScanResult(
original_text,
sanitized_text,
violations=list(),
scan_error=None,
warnings=list(),
context_id="",
_salt="",
_locale="en",
)
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
¶
Unmodified original input. Contains raw PII — do not expose externally.
violations
class-attribute
instance-attribute
¶
List of all detected violations. The original fields contain raw PII.
scan_error
class-attribute
instance-attribute
¶
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
¶
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
¶
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.
token_map
property
¶
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
¶
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, source, and the
sanitized-text offsets). Raw PII is not included.
Source code in src/wardcat/core/models.py
restore
¶
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: |
None
|
strict
|
bool
|
raise :class: |
False
|
also
|
Iterable[ScanResult]
|
other results whose placeholders are legitimate here. |
()
|
Returns:
| Type | Description |
|---|---|
RestoredText
|
a :class: |
Source code in src/wardcat/core/models.py
reapply
¶
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: |
required |
entities
|
Iterable[str] | None
|
optional subset of entity types to re-anonymize;
|
None
|
Returns:
| Type | Description |
|---|---|
ScanResult
|
a new :class: |
Raises:
| Type | Description |
|---|---|
ConfigError
|
if |
Source code in src/wardcat/core/models.py
wardcat.Violation
dataclass
¶
Violation(
entity_type,
original,
start,
end,
action,
replacement=None,
confidence=1.0,
source="",
sanitized_start=-1,
sanitized_end=-1,
)
A single PII violation detected in the text.
action
instance-attribute
¶
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
¶
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
¶
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
source
class-attribute
instance-attribute
¶
Which layer found it: "regex", "ner", "llm", "denylist",
"propagation" (a copy of a value another layer found), or "custom"
for a third-party detector that did not name itself. Where two layers found
the same span, the one that won the overlap.
sanitized_start
class-attribute
instance-attribute
¶
Start index of :attr:replacement in sanitized_text; equals
:attr:start shifted by the replacements before it. -1 when unknown.
sanitized_end
class-attribute
instance-attribute
¶
End index (exclusive) of :attr:replacement in sanitized_text. For
warn, which replaces nothing, the original value's position there.
wardcat.RedactedResult
¶
Bases: TypedDict
The PII-free dict returned by :meth:ScanResult.redacted (safe to log).
wardcat.RestoredText
dataclass
¶
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
¶
The text with every unambiguous placeholder replaced by its original value.
substitutions
class-attribute
instance-attribute
¶
What was put back, ordered by first appearance in :attr:text.
unrestored
class-attribute
instance-attribute
¶
Detected values that were not put back, with the reason for each.
is_complete
property
¶
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
¶
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
with_sources
¶
:attr:text with :meth:sources_block appended below it.
Source code in src/wardcat/core/restore.py
wardcat.Substitution
dataclass
¶
Substitution(
index,
entity_type,
action,
placeholder,
original,
occurrences,
confidence,
source="",
)
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
¶
1-based position in the source list, ordered by first appearance in the text.
occurrences
instance-attribute
¶
How many times the placeholder appeared in the restored text.
source
class-attribute
instance-attribute
¶
The layer that found the value ("regex", "ner", "llm", …).
wardcat.UnrestoredValue
dataclass
¶
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
¶
None for report-only actions such as warn, which replace nothing.
reason
instance-attribute
¶
"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.
wardcat.SensitivityVerdict
dataclass
¶
What :meth:~wardcat.Wardcat.classify decided about a text.
categories draws from :data:SENSITIVITY_CATEGORIES, plus "unknown"
when the model said the text is sensitive but its answer could not be read
in full — the verdict fails closed rather than clean. reason is the
model's own one-line justification and may quote the text: treat it as
sensitive as the input.
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
¶
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
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.
wardcat.DegradedScanError
¶
Bases: WardcatError
A strict guard refused to return a scan that covered less than configured.
Raised only when the guard was built with with_strict() (or strict:
true in YAML). Without strict mode the same condition is reported on
:attr:ScanResult.warnings and the partial result is returned.
warnings lists what did not run — a SpaCy model that failed to load, an
LLM backend that was unreachable. result carries the partial
:class:~wardcat.ScanResult when the scan ran and failed only its coverage
check; it is None when the guard refused at build time.
Source code in src/wardcat/exceptions.py
wardcat.CircuitOpen
¶
Bases: WardcatError
The LLM backend is being skipped after repeated failures.
retry_after is how many seconds remain before the next trial call.
Source code in src/wardcat/llm/circuit.py
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
¶
Sentinel selecting every known entity type (not a real entity).
HIGH_ENTROPY_STRING
class-attribute
instance-attribute
¶
A long token that looks like a secret with no known prefix and no keyword
beside it: 32+ base64-shaped characters with high Shannon entropy, or a hex
digest longer than a git SHA. Scored 0.70, under the default floor, so it
only acts when enabled with its own min_confidence: it is a guess by
construction, and long tokens of many innocent kinds look the same.
wardcat.Action
¶
Bases: str, Enum
Action to apply to detected PII.
WARN
class-attribute
instance-attribute
¶
Leave the text as-is, report only as a violation.
HASH
class-attribute
instance-attribute
¶
Mask with SHA-256 + salt: [ENTITY_TYPE:abcd1234].
REDACT
class-attribute
instance-attribute
¶
Replace with a plain label: [ENTITY_TYPE] — no hash, no original value.
MASK
class-attribute
instance-attribute
¶
Partially obscure the value, entity-aware. Most types reveal only the last
few characters — e.g. CREDIT_CARD → ************1111,
EMAIL → u***@example.com, SSN → ***-**-6789. Types without a
specific rule fall back to first 2 + * + last 2 (abcdef → ab**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
¶
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.
SURROGATE
class-attribute
instance-attribute
¶
Reversible replacement with a realistic stand-in of the same shape — a
name from the guard's locale for a name, an address on a reserved domain for
an e-mail, a Luhn-valid number for a card. The same value gets the same
surrogate under the same salt, and no two values share one within a scan.
A type without a generator falls back to tokenize and the violation says
so. Surrogates look real, which is the point and the risk: see the security
guide.
wardcat.Layer
¶
Bases: str, Enum
A detector layer, as named in add_entity(..., layers=[...]) and in
:attr:Violation.source.
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
¶
Local Ollama service (supports model download).
OPENAI_COMPATIBLE
class-attribute
instance-attribute
¶
OpenAI-compatible HTTP API — LM Studio, LocalAI, LiteLLM, …
VLLM
class-attribute
instance-attribute
¶
vLLM server (OpenAI-compatible API) — native chat, vLLM defaults.
TRANSFORMERS
class-attribute
instance-attribute
¶
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.