maskflow-langchain 0.1.0__tar.gz

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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ .env
6
+ node_modules/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .DS_Store
11
+ .pytest_cache/
12
+ .idea/
13
+ .coverage
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ bench/indiapii/quality/.cache/
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.5
2
+ Name: maskflow-langchain
3
+ Version: 0.1.0
4
+ Summary: MaskFlow for LangChain: a reversible PII anonymizer/deanonymizer pair (drop-in for langchain-experimental's Presidio anonymizer) plus a leak-guard callback. Indian identifiers included.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: langchain-core<2,>=0.3
8
+ Requires-Dist: maskflow-sdk<0.9,>=0.8.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: pyyaml>=6.0; extra == 'dev'
14
+ Provides-Extra: yaml
15
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # maskflow-langchain
19
+
20
+ MaskFlow for [LangChain](https://github.com/langchain-ai/langchain): a
21
+ reversible PII anonymizer / deanonymizer pair that drops in for
22
+ `langchain-experimental`'s Presidio anonymizer, plus a leak-guard callback.
23
+
24
+ It runs MaskFlow's detection engine, so alongside the usual PII (email,
25
+ phone, card numbers, ...) it covers the **Indian identifiers** most tools
26
+ miss: Aadhaar, PAN, GSTIN, UPI VPA, IFSC, ABHA, Indian mobile / PIN code /
27
+ voter ID / passport / driving licence / vehicle registration, and Indian
28
+ names and addresses.
29
+
30
+ - **Drop-in.** Same method names and mapping shapes as
31
+ `PresidioReversibleAnonymizer`, so migrating a chain is one import line.
32
+ - **Streaming.** `anonymizer.deanonymizer` is a streaming-aware `Runnable`;
33
+ a placeholder split across two streamed chunks is stitched back before
34
+ the caller sees it. (Presidio's `RunnableLambda(deanonymize)` only fires
35
+ on the final string.)
36
+ - **Leak guard.** An optional callback that fails a call closed if a prompt
37
+ still contains PII.
38
+ - **MIT, no gates, no telemetry.**
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install maskflow-langchain
44
+ pip install "maskflow-langchain[yaml]" # if you save/load mappings as .yaml
45
+ ```
46
+
47
+ `langchain-core` is a real dependency (`>=0.3,<2`). The first detection run
48
+ downloads a small spaCy model for the name/address recognizers; pass
49
+ `patterns_only=True` to skip it.
50
+
51
+ ## Migrating from the Presidio anonymizer
52
+
53
+ ```python
54
+ # from langchain_experimental.data_anonymizer import PresidioReversibleAnonymizer
55
+ from maskflow_langchain import MaskflowReversibleAnonymizer as PresidioReversibleAnonymizer
56
+ ```
57
+
58
+ Everything a chain touches keeps working: `.anonymize(text, language=None,
59
+ allow_list=None)`, `.deanonymize(text, strategy=exact_matching_strategy)`,
60
+ `.reset_deanonymizer_mapping()`, `.deanonymizer_mapping`,
61
+ `.anonymizer_mapping`, `.save_deanonymizer_mapping(path)`,
62
+ `.load_deanonymizer_mapping(path)`.
63
+
64
+ Two methods differ, because Presidio recognizer and operator objects have
65
+ no MaskFlow equivalent:
66
+
67
+ | Presidio | maskflow-langchain |
68
+ |---|---|
69
+ | `add_recognizer(recognizer_obj)` | `add_recognizer(entity_type=..., regex=..., base_confidence=0.6)` |
70
+ | `add_operators({e: OperatorConfig(...)})` | `add_operators({e: "replace"\|"redact"\|"mask"\|"hash"\|"surrogate"})` |
71
+
72
+ `allow_list` is passed to the **constructor** on the reversible anonymizer
73
+ (the session is built once); a differing per-call `allow_list` raises.
74
+
75
+ ## Use it in a chain
76
+
77
+ ```python
78
+ from langchain_core.output_parsers import StrOutputParser
79
+ from langchain_core.prompts import ChatPromptTemplate
80
+ from maskflow_langchain import MaskflowReversibleAnonymizer
81
+
82
+ anonymizer = MaskflowReversibleAnonymizer()
83
+ prompt = ChatPromptTemplate.from_template("Answer: {question}")
84
+
85
+ chain = (
86
+ {"question": lambda x: anonymizer.anonymize(x["question"])}
87
+ | prompt
88
+ | llm
89
+ | StrOutputParser()
90
+ | anonymizer.deanonymizer # streaming-aware
91
+ )
92
+
93
+ chain.invoke({"question": "Is PAN ABCPE1234F valid for a salaried filer?"})
94
+ # the LLM sees "<PAN_1>"; you get "ABCPE1234F" back
95
+ for piece in chain.stream({"question": "Confirm receipt of PAN ABCPE1234F"}):
96
+ print(piece, end="") # deanonymized incrementally
97
+ ```
98
+
99
+ ## Leak-guard callback
100
+
101
+ ```python
102
+ from maskflow_langchain import MaskflowLeakGuardCallback
103
+
104
+ guard = MaskflowLeakGuardCallback(raise_on_prompt_pii=True)
105
+ chain.invoke(x, config={"callbacks": [guard]})
106
+ # raises MaskflowPIILeakError if a prompt reaching the LLM still has PII
107
+
108
+ guard.summary() # {"prompt": {"PAN": 0}, "completion": {...}} -- counts only, never values
109
+ ```
110
+
111
+ Callbacks cannot rewrite prompts, so this does not mask; it audits (entity
112
+ types and counts, never values) and, with `raise_on_prompt_pii=True`, aborts
113
+ a call that would leak.
114
+
115
+ ## PII safety
116
+
117
+ No original value is written to logs, `repr`, callback state, or a saved
118
+ mapping's structure beyond what you explicitly persist with
119
+ `save_deanonymizer_mapping` (which, like Presidio's, contains the real
120
+ values -- treat that file as sensitive).
121
+
122
+ See `docs/langchain.md` in the MaskFlow repo for design notes.
@@ -0,0 +1,105 @@
1
+ # maskflow-langchain
2
+
3
+ MaskFlow for [LangChain](https://github.com/langchain-ai/langchain): a
4
+ reversible PII anonymizer / deanonymizer pair that drops in for
5
+ `langchain-experimental`'s Presidio anonymizer, plus a leak-guard callback.
6
+
7
+ It runs MaskFlow's detection engine, so alongside the usual PII (email,
8
+ phone, card numbers, ...) it covers the **Indian identifiers** most tools
9
+ miss: Aadhaar, PAN, GSTIN, UPI VPA, IFSC, ABHA, Indian mobile / PIN code /
10
+ voter ID / passport / driving licence / vehicle registration, and Indian
11
+ names and addresses.
12
+
13
+ - **Drop-in.** Same method names and mapping shapes as
14
+ `PresidioReversibleAnonymizer`, so migrating a chain is one import line.
15
+ - **Streaming.** `anonymizer.deanonymizer` is a streaming-aware `Runnable`;
16
+ a placeholder split across two streamed chunks is stitched back before
17
+ the caller sees it. (Presidio's `RunnableLambda(deanonymize)` only fires
18
+ on the final string.)
19
+ - **Leak guard.** An optional callback that fails a call closed if a prompt
20
+ still contains PII.
21
+ - **MIT, no gates, no telemetry.**
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install maskflow-langchain
27
+ pip install "maskflow-langchain[yaml]" # if you save/load mappings as .yaml
28
+ ```
29
+
30
+ `langchain-core` is a real dependency (`>=0.3,<2`). The first detection run
31
+ downloads a small spaCy model for the name/address recognizers; pass
32
+ `patterns_only=True` to skip it.
33
+
34
+ ## Migrating from the Presidio anonymizer
35
+
36
+ ```python
37
+ # from langchain_experimental.data_anonymizer import PresidioReversibleAnonymizer
38
+ from maskflow_langchain import MaskflowReversibleAnonymizer as PresidioReversibleAnonymizer
39
+ ```
40
+
41
+ Everything a chain touches keeps working: `.anonymize(text, language=None,
42
+ allow_list=None)`, `.deanonymize(text, strategy=exact_matching_strategy)`,
43
+ `.reset_deanonymizer_mapping()`, `.deanonymizer_mapping`,
44
+ `.anonymizer_mapping`, `.save_deanonymizer_mapping(path)`,
45
+ `.load_deanonymizer_mapping(path)`.
46
+
47
+ Two methods differ, because Presidio recognizer and operator objects have
48
+ no MaskFlow equivalent:
49
+
50
+ | Presidio | maskflow-langchain |
51
+ |---|---|
52
+ | `add_recognizer(recognizer_obj)` | `add_recognizer(entity_type=..., regex=..., base_confidence=0.6)` |
53
+ | `add_operators({e: OperatorConfig(...)})` | `add_operators({e: "replace"\|"redact"\|"mask"\|"hash"\|"surrogate"})` |
54
+
55
+ `allow_list` is passed to the **constructor** on the reversible anonymizer
56
+ (the session is built once); a differing per-call `allow_list` raises.
57
+
58
+ ## Use it in a chain
59
+
60
+ ```python
61
+ from langchain_core.output_parsers import StrOutputParser
62
+ from langchain_core.prompts import ChatPromptTemplate
63
+ from maskflow_langchain import MaskflowReversibleAnonymizer
64
+
65
+ anonymizer = MaskflowReversibleAnonymizer()
66
+ prompt = ChatPromptTemplate.from_template("Answer: {question}")
67
+
68
+ chain = (
69
+ {"question": lambda x: anonymizer.anonymize(x["question"])}
70
+ | prompt
71
+ | llm
72
+ | StrOutputParser()
73
+ | anonymizer.deanonymizer # streaming-aware
74
+ )
75
+
76
+ chain.invoke({"question": "Is PAN ABCPE1234F valid for a salaried filer?"})
77
+ # the LLM sees "<PAN_1>"; you get "ABCPE1234F" back
78
+ for piece in chain.stream({"question": "Confirm receipt of PAN ABCPE1234F"}):
79
+ print(piece, end="") # deanonymized incrementally
80
+ ```
81
+
82
+ ## Leak-guard callback
83
+
84
+ ```python
85
+ from maskflow_langchain import MaskflowLeakGuardCallback
86
+
87
+ guard = MaskflowLeakGuardCallback(raise_on_prompt_pii=True)
88
+ chain.invoke(x, config={"callbacks": [guard]})
89
+ # raises MaskflowPIILeakError if a prompt reaching the LLM still has PII
90
+
91
+ guard.summary() # {"prompt": {"PAN": 0}, "completion": {...}} -- counts only, never values
92
+ ```
93
+
94
+ Callbacks cannot rewrite prompts, so this does not mask; it audits (entity
95
+ types and counts, never values) and, with `raise_on_prompt_pii=True`, aborts
96
+ a call that would leak.
97
+
98
+ ## PII safety
99
+
100
+ No original value is written to logs, `repr`, callback state, or a saved
101
+ mapping's structure beyond what you explicitly persist with
102
+ `save_deanonymizer_mapping` (which, like Presidio's, contains the real
103
+ values -- treat that file as sensitive).
104
+
105
+ See `docs/langchain.md` in the MaskFlow repo for design notes.
@@ -0,0 +1,22 @@
1
+ # Runnable example
2
+
3
+ `anonymized_chain.py` builds an LCEL chain that masks PII before the model
4
+ and restores it after, and attaches the leak-guard callback.
5
+
6
+ ```bash
7
+ pip install maskflow-langchain
8
+ python packages/maskflow-langchain/examples/anonymized_chain.py
9
+ ```
10
+
11
+ With `OPENAI_API_KEY` set it calls `gpt-4o-mini`; without one it uses a fake
12
+ chat model so the mask/restore round-trip is still visible:
13
+
14
+ ```
15
+ you asked : Please file the return for PAN ABCPE1234F, UPI ramesh@oksbi, email ramesh@example.com.
16
+ model saw : Please file the return for PAN <PAN_1>, UPI <UPI_VPA_1>, email <EMAIL_1>.
17
+ you get back: ...
18
+ ```
19
+
20
+ `chain.stream(...)` deanonymizes incrementally through
21
+ `anonymizer.deanonymizer`; `guard.summary()` reports detected entity types
22
+ and counts, never values.
@@ -0,0 +1,57 @@
1
+ """Runnable example: a LangChain LCEL chain with MaskFlow anonymization.
2
+
3
+ pip install maskflow-langchain "langchain[openai]"
4
+ export OPENAI_API_KEY=sk-...
5
+ python packages/maskflow-langchain/examples/anonymized_chain.py
6
+
7
+ Swap the model line for any chat model. With no API key it falls back to a
8
+ fake model that echoes the (masked) prompt, so the round-trip is still
9
+ visible.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ from langchain_core.output_parsers import StrOutputParser
17
+ from langchain_core.prompts import ChatPromptTemplate
18
+ from maskflow_langchain import MaskflowLeakGuardCallback, MaskflowReversibleAnonymizer
19
+
20
+
21
+ def _model(): # noqa: ANN202
22
+ if os.getenv("OPENAI_API_KEY"):
23
+ from langchain_openai import ChatOpenAI
24
+
25
+ return ChatOpenAI(model="gpt-4o-mini", temperature=0)
26
+ from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
27
+ from langchain_core.messages import AIMessage
28
+
29
+ return FakeMessagesListChatModel(
30
+ responses=[AIMessage("Noted. I will reference the details you provided.")]
31
+ )
32
+
33
+
34
+ anonymizer = MaskflowReversibleAnonymizer()
35
+ prompt = ChatPromptTemplate.from_template(
36
+ "A user wrote: {question}\nAcknowledge and restate the identifiers you saw."
37
+ )
38
+
39
+ chain = (
40
+ {"question": lambda x: anonymizer.anonymize(x["question"])}
41
+ | prompt
42
+ | _model()
43
+ | StrOutputParser()
44
+ | anonymizer.deanonymizer
45
+ )
46
+
47
+ question = "Please file the return for PAN ABCPE1234F, UPI ramesh@oksbi, email ramesh@example.com."
48
+
49
+ guard = MaskflowLeakGuardCallback()
50
+ print("you asked :", question)
51
+ print("model saw :", anonymizer.anonymize(question))
52
+ print("you get back:", chain.invoke({"question": question}, config={"callbacks": [guard]}))
53
+ print("streamed :", end=" ")
54
+ for piece in chain.stream({"question": question}):
55
+ print(piece, end="", flush=True)
56
+ print()
57
+ print("audit :", guard.summary())
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "maskflow-langchain"
3
+ version = "0.1.0"
4
+ description = "MaskFlow for LangChain: a reversible PII anonymizer/deanonymizer pair (drop-in for langchain-experimental's Presidio anonymizer) plus a leak-guard callback. Indian identifiers included."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ # The masking engine, session identity (stable token <-> value), and the
10
+ # fuzz-tested StreamingUnmasker (0.8.0 -> maskflow.streaming) that the
11
+ # streaming deanonymizer is built on.
12
+ "maskflow-sdk>=0.8.0,<0.9",
13
+ # BaseCallbackHandler + Runnable. Pure-python, small; a real dependency,
14
+ # not a peer -- you install maskflow-langchain to use it *with* LangChain.
15
+ # Range spans the 0.3 line (still widely deployed) and 1.x.
16
+ "langchain-core>=0.3,<2",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ # save_deanonymizer_mapping / load_deanonymizer_mapping with .yaml paths.
21
+ yaml = [
22
+ "pyyaml>=6.0",
23
+ ]
24
+ dev = [
25
+ "pytest>=8.0",
26
+ "pytest-asyncio>=0.23",
27
+ "hypothesis>=6.100",
28
+ "pyyaml>=6.0",
29
+ ]
30
+
31
+ [tool.uv.sources]
32
+ maskflow-sdk = { workspace = true }
33
+
34
+ [tool.uv]
35
+ package = true
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/maskflow_langchain"]
43
+
44
+ [tool.pytest.ini_options]
45
+ asyncio_mode = "auto"
46
+ markers = [
47
+ "leak: guards against PII reaching logs, repr, callback state, or saved mappings",
48
+ ]
@@ -0,0 +1,47 @@
1
+ """MaskFlow for LangChain.
2
+
3
+ A reversible PII anonymizer / deanonymizer pair that drops in for
4
+ ``langchain_experimental.data_anonymizer``'s Presidio anonymizer (Indian
5
+ identifiers included), plus a leak-guard callback.
6
+
7
+ from maskflow_langchain import MaskflowReversibleAnonymizer
8
+
9
+ anonymizer = MaskflowReversibleAnonymizer()
10
+ chain = (
11
+ {"question": lambda x: anonymizer.anonymize(x["question"])}
12
+ | prompt
13
+ | llm
14
+ | StrOutputParser()
15
+ | anonymizer.deanonymizer # streaming-aware Runnable
16
+ )
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .anonymizer import MaskflowAnonymizer, MaskflowReversibleAnonymizer
22
+ from .base import AnonymizerBase, ReversibleAnonymizerBase
23
+ from .callbacks import (
24
+ AsyncMaskflowLeakGuardCallback,
25
+ MaskflowLeakGuardCallback,
26
+ MaskflowPIILeakError,
27
+ )
28
+ from .matching import (
29
+ MappingDataType,
30
+ case_insensitive_matching_strategy,
31
+ exact_matching_strategy,
32
+ )
33
+ from .runnables import MaskflowDeanonymizer
34
+
35
+ __all__ = [
36
+ "MaskflowAnonymizer",
37
+ "MaskflowReversibleAnonymizer",
38
+ "MaskflowDeanonymizer",
39
+ "MaskflowLeakGuardCallback",
40
+ "AsyncMaskflowLeakGuardCallback",
41
+ "MaskflowPIILeakError",
42
+ "AnonymizerBase",
43
+ "ReversibleAnonymizerBase",
44
+ "MappingDataType",
45
+ "exact_matching_strategy",
46
+ "case_insensitive_matching_strategy",
47
+ ]
@@ -0,0 +1,52 @@
1
+ """Conversions between a ``maskflow.Session`` mapping and LangChain's
2
+ ``MappingDataType`` (``{entity_type: {anonymized: original}}``).
3
+
4
+ The nested-by-entity-type shape is what ``PresidioReversibleAnonymizer``
5
+ exposes as ``.deanonymizer_mapping`` / ``.anonymizer_mapping`` and what its
6
+ ``save``/``load`` round-trips, so a chain that inspects or persists the
7
+ mapping keeps working after the import swap.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from maskflow import Session
13
+
14
+ from .matching import MappingDataType
15
+
16
+
17
+ def session_deanonymizer_mapping(session: Session) -> MappingDataType:
18
+ """``{ENTITY: {<token>: original}}`` for every reversible entry in the
19
+ session's current mapping."""
20
+ out: MappingDataType = {}
21
+ mapping = session.mapping
22
+ for token in mapping:
23
+ entry = mapping[token]
24
+ if not entry.reversible:
25
+ continue
26
+ out.setdefault(entry.entity_type.value, {})[token] = entry.original
27
+ return out
28
+
29
+
30
+ def invert(mapping_data: MappingDataType) -> MappingDataType:
31
+ """``{ENTITY: {token: original}}`` -> ``{ENTITY: {original: token}}``."""
32
+ return {
33
+ entity_type: {original: anon for anon, original in inner.items()}
34
+ for entity_type, inner in mapping_data.items()
35
+ }
36
+
37
+
38
+ def flat_token_pairs(mapping_data: MappingDataType) -> dict[str, str]:
39
+ """Flatten to ``{<token>: original}`` for whole-text or streaming unmask."""
40
+ pairs: dict[str, str] = {}
41
+ for inner in mapping_data.values():
42
+ pairs.update(inner)
43
+ return pairs
44
+
45
+
46
+ def merge_into(dst: MappingDataType, src: MappingDataType) -> None:
47
+ """Merge ``src`` into ``dst`` in place; existing token->original entries
48
+ are not overwritten."""
49
+ for entity_type, inner in src.items():
50
+ target = dst.setdefault(entity_type, {})
51
+ for anon, original in inner.items():
52
+ target.setdefault(anon, original)