credgate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- credgate/__init__.py +47 -0
- credgate/_spec.py +64 -0
- credgate/checksums.py +136 -0
- credgate/cli.py +80 -0
- credgate/detect.py +203 -0
- credgate/detectors.py +271 -0
- credgate/normalise.py +69 -0
- credgate/py.typed +0 -0
- credgate/spec/README.md +111 -0
- credgate/spec/conformance/checksums.yaml +44 -0
- credgate/spec/conformance/detect.yaml +314 -0
- credgate/spec/detectors.yaml +308 -0
- credgate/spec/engine.yaml +42 -0
- credgate/synth.py +112 -0
- credgate-0.1.0.dist-info/METADATA +110 -0
- credgate-0.1.0.dist-info/RECORD +21 -0
- credgate-0.1.0.dist-info/WHEEL +4 -0
- credgate-0.1.0.dist-info/entry_points.txt +2 -0
- credgate-0.1.0.dist-info/licenses/AUTHORS +6 -0
- credgate-0.1.0.dist-info/licenses/LICENSE +202 -0
- credgate-0.1.0.dist-info/licenses/NOTICE +5 -0
credgate/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""CredGate: governance layer for LLM requests with Indian PII detection.
|
|
2
|
+
|
|
3
|
+
Detection rules come from the shared spec (see :func:`spec_path`), so this
|
|
4
|
+
package and the R package find exactly the same matches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("credgate")
|
|
11
|
+
except PackageNotFoundError: # running from a source checkout
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from ._spec import spec_path
|
|
15
|
+
from .checksums import (
|
|
16
|
+
gstin_check_char,
|
|
17
|
+
gstin_valid,
|
|
18
|
+
luhn_check_digit,
|
|
19
|
+
luhn_valid,
|
|
20
|
+
verhoeff_check_digit,
|
|
21
|
+
verhoeff_valid,
|
|
22
|
+
)
|
|
23
|
+
from .detect import Span, detect_pii, mask_value
|
|
24
|
+
from .detectors import Detector, bounded, detectors, register_detector, reset_detectors
|
|
25
|
+
from .normalise import Normalised, normalise
|
|
26
|
+
from .synth import synth_indian_pii
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Detector",
|
|
30
|
+
"Normalised",
|
|
31
|
+
"Span",
|
|
32
|
+
"bounded",
|
|
33
|
+
"detect_pii",
|
|
34
|
+
"detectors",
|
|
35
|
+
"gstin_check_char",
|
|
36
|
+
"gstin_valid",
|
|
37
|
+
"luhn_check_digit",
|
|
38
|
+
"luhn_valid",
|
|
39
|
+
"mask_value",
|
|
40
|
+
"normalise",
|
|
41
|
+
"register_detector",
|
|
42
|
+
"reset_detectors",
|
|
43
|
+
"spec_path",
|
|
44
|
+
"synth_indian_pii",
|
|
45
|
+
"verhoeff_check_digit",
|
|
46
|
+
"verhoeff_valid",
|
|
47
|
+
]
|
credgate/_spec.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Reading the shared detection spec shipped inside the package.
|
|
2
|
+
|
|
3
|
+
The spec (``spec/`` in the repository, copied here by ``tools/sync-spec.sh``)
|
|
4
|
+
is shared with the R package; see its README.md for the schema.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import regex
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
SPEC_VERSION_SUPPORTED = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def spec_path() -> Path:
|
|
21
|
+
"""Path of the ``spec`` directory shipped with the package."""
|
|
22
|
+
return Path(str(resources.files("credgate") / "spec"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_spec_file(name: str, directory: Path | None = None) -> dict[str, Any]:
|
|
26
|
+
path = (directory or spec_path()) / name
|
|
27
|
+
with open(path, encoding="utf-8") as f:
|
|
28
|
+
data = yaml.safe_load(f)
|
|
29
|
+
if data.get("spec_version") != SPEC_VERSION_SUPPORTED:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"Unsupported spec_version {data.get('spec_version')} in {name} "
|
|
32
|
+
f"(this credgate supports {SPEC_VERSION_SUPPORTED})."
|
|
33
|
+
)
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _code_points(values: list[str]) -> frozenset[int]:
|
|
38
|
+
return frozenset(int(v.removeprefix("U+"), 16) for v in values)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@functools.cache
|
|
42
|
+
def engine() -> dict[str, Any]:
|
|
43
|
+
"""Engine parameters from engine.yaml, with code points parsed."""
|
|
44
|
+
spec = read_spec_file("engine.yaml")
|
|
45
|
+
norm = spec["normalisation"]
|
|
46
|
+
return {
|
|
47
|
+
"digit_zeros": sorted(_code_points(norm["digit_zeros"])),
|
|
48
|
+
"remove": _code_points(norm["remove"]),
|
|
49
|
+
"dash": _code_points(norm["dash"]),
|
|
50
|
+
"boundary": spec["boundary"],
|
|
51
|
+
"separators": regex.compile(spec["separators"]),
|
|
52
|
+
"scoring": spec["scoring"],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@functools.cache
|
|
57
|
+
def detector_spec() -> dict[str, Any]:
|
|
58
|
+
"""The parsed detectors.yaml."""
|
|
59
|
+
spec = read_spec_file("detectors.yaml")
|
|
60
|
+
spec["lists"] = {k: [str(x) for x in v] for k, v in spec.get("lists", {}).items()}
|
|
61
|
+
spec["tables"] = {
|
|
62
|
+
k: {str(key): str(val) for key, val in v.items()} for k, v in spec.get("tables", {}).items()
|
|
63
|
+
}
|
|
64
|
+
return spec
|
credgate/checksums.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Checksum algorithms used to validate identifier candidates.
|
|
2
|
+
|
|
3
|
+
All functions ignore spaces and hyphens and treat anything else that is not
|
|
4
|
+
the expected characters as invalid.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import regex
|
|
10
|
+
|
|
11
|
+
from ._spec import detector_spec, engine
|
|
12
|
+
|
|
13
|
+
_VERHOEFF_D = (
|
|
14
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
15
|
+
(1, 2, 3, 4, 0, 6, 7, 8, 9, 5),
|
|
16
|
+
(2, 3, 4, 0, 1, 7, 8, 9, 5, 6),
|
|
17
|
+
(3, 4, 0, 1, 2, 8, 9, 5, 6, 7),
|
|
18
|
+
(4, 0, 1, 2, 3, 9, 5, 6, 7, 8),
|
|
19
|
+
(5, 9, 8, 7, 6, 0, 4, 3, 2, 1),
|
|
20
|
+
(6, 5, 9, 8, 7, 1, 0, 4, 3, 2),
|
|
21
|
+
(7, 6, 5, 9, 8, 2, 1, 0, 4, 3),
|
|
22
|
+
(8, 7, 6, 5, 9, 3, 2, 1, 0, 4),
|
|
23
|
+
(9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
|
|
24
|
+
)
|
|
25
|
+
_VERHOEFF_P = (
|
|
26
|
+
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
|
27
|
+
(1, 5, 7, 6, 2, 8, 3, 0, 9, 4),
|
|
28
|
+
(5, 8, 0, 3, 7, 9, 6, 1, 4, 2),
|
|
29
|
+
(8, 9, 1, 6, 0, 4, 3, 5, 2, 7),
|
|
30
|
+
(9, 4, 5, 3, 1, 2, 6, 8, 7, 0),
|
|
31
|
+
(4, 2, 8, 6, 5, 7, 3, 9, 0, 1),
|
|
32
|
+
(2, 7, 9, 3, 8, 0, 6, 4, 1, 5),
|
|
33
|
+
(7, 0, 4, 6, 9, 1, 3, 2, 5, 8),
|
|
34
|
+
)
|
|
35
|
+
_VERHOEFF_INV = (0, 4, 3, 2, 1, 5, 6, 7, 8, 9)
|
|
36
|
+
|
|
37
|
+
_DIGITS = regex.compile(r"[0-9]+")
|
|
38
|
+
_GSTIN_BODY = regex.compile(r"[0-9A-Z]{14}")
|
|
39
|
+
_GSTIN_FULL = regex.compile(r"[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]")
|
|
40
|
+
_GSTIN_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def strip_separators(x: str) -> str:
|
|
44
|
+
"""Remove the spec's separator characters (spaces and hyphens)."""
|
|
45
|
+
return engine()["separators"].sub("", x)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _digits(x: str | None) -> str | None:
|
|
49
|
+
if x is None:
|
|
50
|
+
return None
|
|
51
|
+
d = strip_separators(x)
|
|
52
|
+
return d if _DIGITS.fullmatch(d) else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _verhoeff_accumulate(digits: str, offset: int) -> int:
|
|
56
|
+
c = 0
|
|
57
|
+
for i, ch in enumerate(reversed(digits)):
|
|
58
|
+
c = _VERHOEFF_D[c][_VERHOEFF_P[(i + offset) % 8][int(ch)]]
|
|
59
|
+
return c
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def verhoeff_valid(x: str | None) -> bool:
|
|
63
|
+
"""Whether a number (with its final check digit) passes Verhoeff.
|
|
64
|
+
|
|
65
|
+
Used by Aadhaar and Aadhaar Virtual ID.
|
|
66
|
+
"""
|
|
67
|
+
d = _digits(x)
|
|
68
|
+
return d is not None and _verhoeff_accumulate(d, 0) == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def verhoeff_check_digit(x: str | None) -> str | None:
|
|
72
|
+
"""The Verhoeff check digit to append to ``x``, or ``None`` if invalid."""
|
|
73
|
+
d = _digits(x)
|
|
74
|
+
if d is None:
|
|
75
|
+
return None
|
|
76
|
+
return str(_VERHOEFF_INV[_verhoeff_accumulate(d, 1)])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _luhn_sum(digits: str, with_check: bool) -> int:
|
|
80
|
+
# With the check digit present, every second digit from the right is
|
|
81
|
+
# doubled; when computing a check digit it is the first, third, ...
|
|
82
|
+
doubled = 1 if with_check else 0
|
|
83
|
+
total = 0
|
|
84
|
+
for i, ch in enumerate(reversed(digits)):
|
|
85
|
+
d = int(ch)
|
|
86
|
+
if i % 2 == doubled:
|
|
87
|
+
d *= 2
|
|
88
|
+
if d > 9:
|
|
89
|
+
d -= 9
|
|
90
|
+
total += d
|
|
91
|
+
return total
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def luhn_valid(x: str | None) -> bool:
|
|
95
|
+
"""Whether a number passes the Luhn checksum (payment cards)."""
|
|
96
|
+
d = _digits(x)
|
|
97
|
+
return d is not None and _luhn_sum(d, with_check=True) % 10 == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def luhn_check_digit(x: str | None) -> str | None:
|
|
101
|
+
"""The Luhn check digit to append to ``x``, or ``None`` if invalid."""
|
|
102
|
+
d = _digits(x)
|
|
103
|
+
if d is None:
|
|
104
|
+
return None
|
|
105
|
+
return str((10 - _luhn_sum(d, with_check=False) % 10) % 10)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def gstin_check_char(x: str | None) -> str | None:
|
|
109
|
+
"""The mod-36 check character for the first 14 characters of a GSTIN."""
|
|
110
|
+
if x is None:
|
|
111
|
+
return None
|
|
112
|
+
x = x.upper()
|
|
113
|
+
if not _GSTIN_BODY.fullmatch(x):
|
|
114
|
+
return None
|
|
115
|
+
total = 0
|
|
116
|
+
for i, ch in enumerate(x, start=1):
|
|
117
|
+
# Counting from the right, the last character has factor 2, then 1, 2...
|
|
118
|
+
addend = _GSTIN_ALPHABET.index(ch) * (2 if i % 2 == 0 else 1)
|
|
119
|
+
total += addend // 36 + addend % 36
|
|
120
|
+
return _GSTIN_ALPHABET[(36 - total % 36) % 36]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gstin_valid(x: str | None) -> bool:
|
|
124
|
+
"""Whether ``x`` is a structurally valid GSTIN with a correct check character.
|
|
125
|
+
|
|
126
|
+
A GSTIN is a 2-digit state code, the holder's PAN, an entity number, the
|
|
127
|
+
letter ``Z`` and the check character.
|
|
128
|
+
"""
|
|
129
|
+
if x is None:
|
|
130
|
+
return False
|
|
131
|
+
x = x.upper()
|
|
132
|
+
return (
|
|
133
|
+
_GSTIN_FULL.fullmatch(x) is not None
|
|
134
|
+
and x[:2] in detector_spec()["lists"]["gst_state_codes"]
|
|
135
|
+
and x[14] == gstin_check_char(x[:14])
|
|
136
|
+
)
|
credgate/cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command-line interface: ``credgate detect``, ``credgate detectors``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from ._spec import spec_path
|
|
12
|
+
from .detect import detect_pii, mask_value
|
|
13
|
+
from .detectors import detectors
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="credgate",
|
|
19
|
+
description="Detect Indian PII before it leaves the machine.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"credgate {__version__}")
|
|
22
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
p = sub.add_parser("detect", help="detect PII in files or standard input")
|
|
25
|
+
p.add_argument("files", nargs="*", help="files to scan (default: standard input)")
|
|
26
|
+
p.add_argument("--lines", action="store_true", help="treat each line as a separate document")
|
|
27
|
+
p.add_argument("--json", action="store_true", help="write one JSON object per match")
|
|
28
|
+
p.add_argument(
|
|
29
|
+
"--reveal",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="show detected values and canonical forms (masked by default)",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument("--min-confidence", type=float, default=0.0, metavar="X")
|
|
34
|
+
p.add_argument("--context", default=None, help="extra context applied to every match")
|
|
35
|
+
p.add_argument(
|
|
36
|
+
"--fail",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="exit with status 1 if anything is detected (for CI and pre-commit hooks)",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
sub.add_parser("detectors", help="list the registered detectors")
|
|
42
|
+
sub.add_parser("spec-path", help="print the path of the shipped detection spec")
|
|
43
|
+
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
if args.command == "detectors":
|
|
46
|
+
for d in detectors():
|
|
47
|
+
print(f"{d['id']:<16} {d['class']:<16} {d['label']}")
|
|
48
|
+
return 0
|
|
49
|
+
if args.command == "spec-path":
|
|
50
|
+
print(spec_path())
|
|
51
|
+
return 0
|
|
52
|
+
return _detect(args)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detect(args: argparse.Namespace) -> int:
|
|
56
|
+
sources = args.files or ["-"]
|
|
57
|
+
found = 0
|
|
58
|
+
for source in sources:
|
|
59
|
+
if source == "-":
|
|
60
|
+
content = sys.stdin.read()
|
|
61
|
+
else:
|
|
62
|
+
with open(source, encoding="utf-8") as f:
|
|
63
|
+
content = f.read()
|
|
64
|
+
docs = content.splitlines() if args.lines else [content]
|
|
65
|
+
spans = detect_pii(docs, context=args.context, min_confidence=args.min_confidence)
|
|
66
|
+
found += len(spans)
|
|
67
|
+
for s in spans:
|
|
68
|
+
location = s.doc + 1 if args.lines else None
|
|
69
|
+
if args.json:
|
|
70
|
+
record = {"source": source, "line": location, **s.to_dict(reveal=args.reveal)}
|
|
71
|
+
print(json.dumps(record, ensure_ascii=False))
|
|
72
|
+
else:
|
|
73
|
+
where = f"{source}:{location}" if location else source
|
|
74
|
+
value = s.value if args.reveal else mask_value(s.value)
|
|
75
|
+
print(f"{where}\t{s.start}-{s.end}\t{s.type}\t{s.confidence:.2f}\t{value}")
|
|
76
|
+
return 1 if args.fail and found else 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
credgate/detect.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""PII detection over text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import regex
|
|
10
|
+
|
|
11
|
+
from ._spec import engine
|
|
12
|
+
from .detectors import Detector, get_detectors
|
|
13
|
+
from .normalise import Normalised, normalise
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Span:
|
|
18
|
+
"""One detected PII match.
|
|
19
|
+
|
|
20
|
+
``start`` and ``end`` are 0-based, end-exclusive code-point offsets into
|
|
21
|
+
the original text of document ``doc``. The representation masks
|
|
22
|
+
``value``; read the attribute to see it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
doc: int
|
|
26
|
+
start: int
|
|
27
|
+
end: int
|
|
28
|
+
type: str
|
|
29
|
+
data_class: str
|
|
30
|
+
value: str
|
|
31
|
+
canonical: str
|
|
32
|
+
confidence: float
|
|
33
|
+
reasons: str
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return (
|
|
37
|
+
f"Span(doc={self.doc}, start={self.start}, end={self.end}, type={self.type!r}, "
|
|
38
|
+
f"value={mask_value(self.value)!r}, confidence={self.confidence})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def to_dict(self, reveal: bool = False) -> dict[str, Any]:
|
|
42
|
+
"""A JSON-ready dict; ``value`` is masked and ``canonical`` omitted unless ``reveal``."""
|
|
43
|
+
d = asdict(self)
|
|
44
|
+
d["class"] = d.pop("data_class")
|
|
45
|
+
if not reveal:
|
|
46
|
+
d["value"] = mask_value(self.value)
|
|
47
|
+
del d["canonical"]
|
|
48
|
+
return d
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_pii(
|
|
52
|
+
text: str | Sequence[str | None],
|
|
53
|
+
detectors: Sequence[str] | None = None,
|
|
54
|
+
context: str | Sequence[str | None] | None = None,
|
|
55
|
+
min_confidence: float = 0.0,
|
|
56
|
+
window: int | None = None,
|
|
57
|
+
) -> list[Span]:
|
|
58
|
+
"""Detect PII in one or more documents.
|
|
59
|
+
|
|
60
|
+
Runs every registered detector over normalised text, validates
|
|
61
|
+
candidates (checksums, code tables), scores them from nearby context and
|
|
62
|
+
resolves overlapping matches (the more confident, then the longer, span
|
|
63
|
+
wins - so a GSTIN is reported once, not also as the PAN inside it).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
text: A string, or a sequence of strings (``None`` counts as empty).
|
|
67
|
+
detectors: Detector ids to run; ``None`` runs all registered ones.
|
|
68
|
+
context: Extra context per document (one string for all), treated as
|
|
69
|
+
if it appeared next to every match - e.g. a column name, so a
|
|
70
|
+
column called ``voter_id`` supports Voter ID matches.
|
|
71
|
+
min_confidence: Drop matches scoring below this value.
|
|
72
|
+
window: Characters either side of a match searched for context
|
|
73
|
+
keywords; ``None`` uses the spec's ``context_window``.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Spans ordered by document and position.
|
|
77
|
+
"""
|
|
78
|
+
docs = [text] if isinstance(text, str) else ["" if t is None else t for t in text]
|
|
79
|
+
if context is None or isinstance(context, str):
|
|
80
|
+
contexts = [context or ""] * len(docs)
|
|
81
|
+
else:
|
|
82
|
+
contexts = ["" if c is None else c for c in context]
|
|
83
|
+
if len(contexts) != len(docs):
|
|
84
|
+
raise ValueError("`context` must be one string or one per document.")
|
|
85
|
+
if window is None:
|
|
86
|
+
window = engine()["scoring"]["context_window"]
|
|
87
|
+
|
|
88
|
+
norms = [normalise(t) for t in docs]
|
|
89
|
+
spans: list[Span] = []
|
|
90
|
+
for det in get_detectors(detectors):
|
|
91
|
+
spans.extend(_run_detector(det, docs, norms, contexts, window))
|
|
92
|
+
spans = _resolve_overlaps(spans)
|
|
93
|
+
spans = [s for s in spans if s.confidence >= min_confidence]
|
|
94
|
+
spans.sort(key=lambda s: (s.doc, s.start))
|
|
95
|
+
return spans
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_detector(
|
|
99
|
+
det: Detector,
|
|
100
|
+
docs: list[str],
|
|
101
|
+
norms: list[Normalised],
|
|
102
|
+
contexts: list[str],
|
|
103
|
+
window: int,
|
|
104
|
+
) -> list[Span]:
|
|
105
|
+
patterns, context_re, negative_re = det._compiled
|
|
106
|
+
scoring = engine()["scoring"]
|
|
107
|
+
out: list[Span] = []
|
|
108
|
+
for doc, (original, norm) in enumerate(zip(docs, norms, strict=True)):
|
|
109
|
+
ntext = norm.text
|
|
110
|
+
for pattern in patterns:
|
|
111
|
+
for m in pattern.finditer(ntext):
|
|
112
|
+
s, e = m.span(det.capture) if det.capture else m.span()
|
|
113
|
+
if s < 0:
|
|
114
|
+
continue
|
|
115
|
+
matched = ntext[s:e]
|
|
116
|
+
if det.validate is not None and not det.validate(matched):
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
around = f"{ntext[max(0, s - window) : s]} {ntext[e : e + window]} {contexts[doc]}"
|
|
120
|
+
ctx_hit = _keyword(context_re, around)
|
|
121
|
+
neg_hit = _keyword(negative_re, around)
|
|
122
|
+
if det.context_required and ctx_hit is None:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
score = det.base(matched) if callable(det.base) else det.base
|
|
126
|
+
if ctx_hit is not None:
|
|
127
|
+
score += (1 - score) * scoring["context_boost"]
|
|
128
|
+
if neg_hit is not None:
|
|
129
|
+
score *= scoring["negative_factor"]
|
|
130
|
+
|
|
131
|
+
reasons = f"matched {det.label} pattern"
|
|
132
|
+
if det.checks:
|
|
133
|
+
reasons += f"; {det.checks}"
|
|
134
|
+
if ctx_hit is not None:
|
|
135
|
+
reasons += f"; context '{ctx_hit}'"
|
|
136
|
+
if neg_hit is not None:
|
|
137
|
+
reasons += f"; negative context '{neg_hit}'"
|
|
138
|
+
if det.describe is not None:
|
|
139
|
+
reasons += f"; {det.describe(matched)}"
|
|
140
|
+
|
|
141
|
+
if norm.map is None:
|
|
142
|
+
start, end = s, e
|
|
143
|
+
else:
|
|
144
|
+
start, end = norm.map[s], norm.map[e - 1] + 1
|
|
145
|
+
out.append(
|
|
146
|
+
Span(
|
|
147
|
+
doc=doc,
|
|
148
|
+
start=start,
|
|
149
|
+
end=end,
|
|
150
|
+
type=det.id,
|
|
151
|
+
data_class=det.data_class,
|
|
152
|
+
value=original[start:end],
|
|
153
|
+
canonical=det.canonical(matched),
|
|
154
|
+
confidence=round(score, scoring["digits"]),
|
|
155
|
+
reasons=reasons,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _keyword(pattern: regex.Pattern[str] | None, text: str) -> str | None:
|
|
162
|
+
if pattern is None:
|
|
163
|
+
return None
|
|
164
|
+
m = pattern.search(text)
|
|
165
|
+
return m.group(0) if m else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_overlaps(spans: list[Span]) -> list[Span]:
|
|
169
|
+
"""Within each cluster of overlapping spans, keep the most confident, then
|
|
170
|
+
the longest, span; repeat with what remains."""
|
|
171
|
+
spans = sorted(spans, key=lambda s: (s.doc, s.start))
|
|
172
|
+
kept: list[Span] = []
|
|
173
|
+
cluster: list[Span] = []
|
|
174
|
+
running_end = -1
|
|
175
|
+
for s in [*spans, None]:
|
|
176
|
+
if s is not None and cluster and s.doc == cluster[0].doc and s.start < running_end:
|
|
177
|
+
cluster.append(s)
|
|
178
|
+
running_end = max(running_end, s.end)
|
|
179
|
+
continue
|
|
180
|
+
if len(cluster) == 1:
|
|
181
|
+
kept.extend(cluster)
|
|
182
|
+
elif cluster:
|
|
183
|
+
taken: list[Span] = []
|
|
184
|
+
for c in sorted(cluster, key=lambda c: (-c.confidence, -(c.end - c.start))):
|
|
185
|
+
if not any(c.start < t.end and c.end > t.start for t in taken):
|
|
186
|
+
taken.append(c)
|
|
187
|
+
kept.extend(taken)
|
|
188
|
+
if s is not None:
|
|
189
|
+
cluster = [s]
|
|
190
|
+
running_end = s.end
|
|
191
|
+
return kept
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
_ALNUM = regex.compile(r"[\p{L}\p{N}]")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def mask_value(x: str) -> str:
|
|
198
|
+
"""Replace letters and digits with ``X``, keeping the last 4 characters
|
|
199
|
+
(2 for values shorter than 8) - the same convention as UIDAI's masked
|
|
200
|
+
Aadhaar."""
|
|
201
|
+
keep = 4 if len(x) >= 8 else 2
|
|
202
|
+
cut = max(0, len(x) - keep)
|
|
203
|
+
return _ALNUM.sub("X", x[:cut]) + x[cut:]
|