pdf-anonymizer-core 0.3.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.
- pdf_anonymizer_core-0.3.0/PKG-INFO +90 -0
- pdf_anonymizer_core-0.3.0/README.md +75 -0
- pdf_anonymizer_core-0.3.0/pyproject.toml +25 -0
- pdf_anonymizer_core-0.3.0/setup.cfg +4 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/__init__.py +0 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/call_llm.py +119 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/conf.py +57 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/core.py +142 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/load_and_extract.py +90 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/prompts/__init__.py +0 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/prompts/detailed.py +55 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/prompts/simple.py +27 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core/utils.py +194 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core.egg-info/PKG-INFO +90 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core.egg-info/SOURCES.txt +16 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core.egg-info/dependency_links.txt +1 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core.egg-info/requires.txt +5 -0
- pdf_anonymizer_core-0.3.0/src/pdf_anonymizer_core.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf-anonymizer-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A core library to anonymize PDF, Markdown, and plain text files using LLMs.
|
|
5
|
+
Author-email: Leonid Ganeline <leo.gan.57@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: repository, https://github.com/leo-gan/anonymizer
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
Requires-Dist: pymupdf4llm
|
|
12
|
+
Requires-Dist: langchain-text-splitters
|
|
13
|
+
Requires-Dist: google-genai
|
|
14
|
+
Requires-Dist: ollama
|
|
15
|
+
|
|
16
|
+
# PDF Anonymizer Core
|
|
17
|
+
|
|
18
|
+
This package provides the core functionality for the PDF/Text anonymizer, including text extraction, LLM-driven anonymization, and deanonymization logic. It is used by `pdf-anonymizer-cli`.
|
|
19
|
+
|
|
20
|
+
## Installation for Development
|
|
21
|
+
|
|
22
|
+
This project uses `uv` and is structured as a monorepo. To install the necessary dependencies for development, run the following command from the root of the repository:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# From the repository root
|
|
26
|
+
uv sync
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This will install the `pdf-anonymizer-core` package in editable mode.
|
|
30
|
+
|
|
31
|
+
## Environment Variables
|
|
32
|
+
|
|
33
|
+
The core library itself does not load `.env` files. Environment variables must be loaded by the application that uses this library (e.g., `pdf-anonymizer-cli`) or set in your shell.
|
|
34
|
+
|
|
35
|
+
- `GOOGLE_API_KEY`: Required when using Google's Gemini models.
|
|
36
|
+
- `OLLAMA_HOST`: Optional, defaults to `http://localhost:11434` when using local Ollama models.
|
|
37
|
+
|
|
38
|
+
## API Usage
|
|
39
|
+
|
|
40
|
+
### `anonymize_file()`
|
|
41
|
+
|
|
42
|
+
Anonymizes a single file and returns the anonymized text and a mapping of original entities to their placeholders.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pdf_anonymizer_core.core import anonymize_file
|
|
46
|
+
from pdf_anonymizer_core.prompts import detailed
|
|
47
|
+
|
|
48
|
+
# Example of programmatic usage
|
|
49
|
+
text, mapping = anonymize_file(
|
|
50
|
+
file_path="/path/to/file.pdf",
|
|
51
|
+
prompt_template=detailed.prompt_template,
|
|
52
|
+
model_name="gemini-2.5-flash"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if text and mapping:
|
|
56
|
+
print("Anonymized Text:", text)
|
|
57
|
+
print("Mapping:", mapping)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `deanonymize_file()`
|
|
61
|
+
|
|
62
|
+
Reverts anonymization using a mapping file.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pdf_anonymizer_core.utils import deanonymize_file
|
|
66
|
+
|
|
67
|
+
# Assumes you have an anonymized file and a mapping file
|
|
68
|
+
deanonymized_text, stats = deanonymize_file(
|
|
69
|
+
anonymized_file="path/to/anonymized.md",
|
|
70
|
+
mapping_file="path/to/mapping.json"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if deanonymized_text:
|
|
74
|
+
print("Deanonymized Text:", deanonymized_text)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Configuration
|
|
78
|
+
|
|
79
|
+
You can import default configurations and available models from the `conf` module.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from pdf_anonymizer_core.conf import (
|
|
83
|
+
DEFAULT_MODEL_NAME,
|
|
84
|
+
ModelName,
|
|
85
|
+
PromptEnum,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
print(f"Default model: {DEFAULT_MODEL_NAME}")
|
|
89
|
+
print(f"Available Google models: {[m.value for m in ModelName if 'gemini' in m.value]}")
|
|
90
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# PDF Anonymizer Core
|
|
2
|
+
|
|
3
|
+
This package provides the core functionality for the PDF/Text anonymizer, including text extraction, LLM-driven anonymization, and deanonymization logic. It is used by `pdf-anonymizer-cli`.
|
|
4
|
+
|
|
5
|
+
## Installation for Development
|
|
6
|
+
|
|
7
|
+
This project uses `uv` and is structured as a monorepo. To install the necessary dependencies for development, run the following command from the root of the repository:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# From the repository root
|
|
11
|
+
uv sync
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This will install the `pdf-anonymizer-core` package in editable mode.
|
|
15
|
+
|
|
16
|
+
## Environment Variables
|
|
17
|
+
|
|
18
|
+
The core library itself does not load `.env` files. Environment variables must be loaded by the application that uses this library (e.g., `pdf-anonymizer-cli`) or set in your shell.
|
|
19
|
+
|
|
20
|
+
- `GOOGLE_API_KEY`: Required when using Google's Gemini models.
|
|
21
|
+
- `OLLAMA_HOST`: Optional, defaults to `http://localhost:11434` when using local Ollama models.
|
|
22
|
+
|
|
23
|
+
## API Usage
|
|
24
|
+
|
|
25
|
+
### `anonymize_file()`
|
|
26
|
+
|
|
27
|
+
Anonymizes a single file and returns the anonymized text and a mapping of original entities to their placeholders.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pdf_anonymizer_core.core import anonymize_file
|
|
31
|
+
from pdf_anonymizer_core.prompts import detailed
|
|
32
|
+
|
|
33
|
+
# Example of programmatic usage
|
|
34
|
+
text, mapping = anonymize_file(
|
|
35
|
+
file_path="/path/to/file.pdf",
|
|
36
|
+
prompt_template=detailed.prompt_template,
|
|
37
|
+
model_name="gemini-2.5-flash"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if text and mapping:
|
|
41
|
+
print("Anonymized Text:", text)
|
|
42
|
+
print("Mapping:", mapping)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `deanonymize_file()`
|
|
46
|
+
|
|
47
|
+
Reverts anonymization using a mapping file.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pdf_anonymizer_core.utils import deanonymize_file
|
|
51
|
+
|
|
52
|
+
# Assumes you have an anonymized file and a mapping file
|
|
53
|
+
deanonymized_text, stats = deanonymize_file(
|
|
54
|
+
anonymized_file="path/to/anonymized.md",
|
|
55
|
+
mapping_file="path/to/mapping.json"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if deanonymized_text:
|
|
59
|
+
print("Deanonymized Text:", deanonymized_text)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Configuration
|
|
63
|
+
|
|
64
|
+
You can import default configurations and available models from the `conf` module.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pdf_anonymizer_core.conf import (
|
|
68
|
+
DEFAULT_MODEL_NAME,
|
|
69
|
+
ModelName,
|
|
70
|
+
PromptEnum,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
print(f"Default model: {DEFAULT_MODEL_NAME}")
|
|
74
|
+
print(f"Available Google models: {[m.value for m in ModelName if 'gemini' in m.value]}")
|
|
75
|
+
```
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pdf-anonymizer-core"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "A core library to anonymize PDF, Markdown, and plain text files using LLMs."
|
|
5
|
+
authors = [{ name = "Leonid Ganeline", email = "leo.gan.57@gmail.com" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"python-dotenv",
|
|
11
|
+
"pymupdf4llm",
|
|
12
|
+
"langchain-text-splitters",
|
|
13
|
+
"google-genai",
|
|
14
|
+
"ollama",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
repository = "https://github.com/leo-gan/anonymizer"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["setuptools>=61.0"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
package-dir = {"" = "src"}
|
|
File without changes
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, List, TypedDict, Union
|
|
5
|
+
|
|
6
|
+
import ollama
|
|
7
|
+
from google import genai
|
|
8
|
+
|
|
9
|
+
from pdf_anonymizer_core.conf import ModelName, ModelProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Type definitions for better code clarity
|
|
13
|
+
class OllamaResponse(TypedDict):
|
|
14
|
+
message: Dict[str, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModelResponse(TypedDict):
|
|
18
|
+
text: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Entity(TypedDict):
|
|
22
|
+
text: str
|
|
23
|
+
type: str
|
|
24
|
+
base_form: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IdentificationResult(TypedDict):
|
|
28
|
+
entities: List[Entity]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def identify_entities_with_llm(
|
|
32
|
+
text: str,
|
|
33
|
+
prompt_template: str,
|
|
34
|
+
model_name: str,
|
|
35
|
+
) -> List[Entity]:
|
|
36
|
+
"""
|
|
37
|
+
Identifies PII entities in a text chunk using a specified language model.
|
|
38
|
+
It retries on failure up to a maximum of 3 times.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
text: The text to analyze.
|
|
42
|
+
prompt_template: The prompt template for the identification task.
|
|
43
|
+
model_name: The name of the model to use.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A list of identified entities.
|
|
47
|
+
"""
|
|
48
|
+
prompt = prompt_template.format(text=text)
|
|
49
|
+
|
|
50
|
+
response: Union[OllamaResponse, ModelResponse, None] = None
|
|
51
|
+
max_retries = 3
|
|
52
|
+
|
|
53
|
+
for attempt in range(max_retries):
|
|
54
|
+
try:
|
|
55
|
+
logging.info(
|
|
56
|
+
f"Calling '{model_name}': text: {len(text):,}, attempt {attempt + 1}"
|
|
57
|
+
)
|
|
58
|
+
model_enum = ModelName(model_name)
|
|
59
|
+
|
|
60
|
+
if model_enum.provider == ModelProvider.OLLAMA:
|
|
61
|
+
response = ollama.chat(
|
|
62
|
+
model=model_name,
|
|
63
|
+
messages=[{"role": "user", "content": prompt}],
|
|
64
|
+
)
|
|
65
|
+
raw_text: str = response["message"]["content"]
|
|
66
|
+
else:
|
|
67
|
+
client = genai.Client()
|
|
68
|
+
response = client.models.generate_content(
|
|
69
|
+
model=model_name, contents=prompt
|
|
70
|
+
)
|
|
71
|
+
raw_text = response.text
|
|
72
|
+
|
|
73
|
+
cleaned_response = (
|
|
74
|
+
raw_text.strip().replace("```json", "").replace("```", "").strip()
|
|
75
|
+
)
|
|
76
|
+
result: IdentificationResult = json.loads(cleaned_response)
|
|
77
|
+
|
|
78
|
+
return result.get("entities", [])
|
|
79
|
+
|
|
80
|
+
except json.JSONDecodeError as e:
|
|
81
|
+
response_text = _get_response_text(response, model_name)
|
|
82
|
+
logging.error(
|
|
83
|
+
f"Attempt {attempt + 1} failed with JSON decode error: {e}, "
|
|
84
|
+
f"response: {response_text[:200]}..."
|
|
85
|
+
)
|
|
86
|
+
if attempt + 1 == max_retries:
|
|
87
|
+
logging.error("Max retries reached. Returning empty list.")
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logging.error(f"Attempt {attempt + 1} failed with an error: {e}")
|
|
92
|
+
if attempt + 1 == max_retries:
|
|
93
|
+
logging.error("Max retries reached. Returning empty list.")
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
time.sleep(1) # Wait before retrying
|
|
97
|
+
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_response_text(
|
|
102
|
+
response: Union[OllamaResponse, ModelResponse, None], model_name: str
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Extract text content from different response types."""
|
|
105
|
+
if not response:
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
model_enum = ModelName(model_name)
|
|
109
|
+
if model_enum.provider == ModelProvider.OLLAMA:
|
|
110
|
+
if (
|
|
111
|
+
isinstance(response, dict)
|
|
112
|
+
and "message" in response
|
|
113
|
+
and "content" in response["message"]
|
|
114
|
+
):
|
|
115
|
+
return response["message"]["content"]
|
|
116
|
+
elif hasattr(response, "text"):
|
|
117
|
+
return response.text
|
|
118
|
+
|
|
119
|
+
return ""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Type, TypeVar
|
|
3
|
+
|
|
4
|
+
# Default values
|
|
5
|
+
DEFAULT_CHARACTERS_TO_ANONYMIZE: int = 100000
|
|
6
|
+
DEFAULT_PROMPT_NAME: str = "detailed"
|
|
7
|
+
DEFAULT_MODEL_NAME: str = "gemini-2.5-flash"
|
|
8
|
+
|
|
9
|
+
# Type variable for enum values
|
|
10
|
+
T = TypeVar("T", bound=Enum)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Enum for prompt names
|
|
14
|
+
class PromptEnum(str, Enum):
|
|
15
|
+
simple = "simple"
|
|
16
|
+
detailed = "detailed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ModelProvider(str, Enum):
|
|
20
|
+
GOOGLE = "google"
|
|
21
|
+
OLLAMA = "ollama"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Then you could associate a provider with each model, for instance:
|
|
25
|
+
class ModelName(str, Enum):
|
|
26
|
+
gemini_2_5_pro = "gemini-2.5-pro"
|
|
27
|
+
gemini_2_5_flash = "gemini-2.5-flash"
|
|
28
|
+
gemini_2_5_flash_lite = "gemini-2.5-flash-lite"
|
|
29
|
+
gemma = "gemma:7b"
|
|
30
|
+
phi = "phi4-mini"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def provider(self) -> "ModelProvider":
|
|
34
|
+
if "gemini" in self.value:
|
|
35
|
+
return ModelProvider.GOOGLE
|
|
36
|
+
return ModelProvider.OLLAMA
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_enum_value(enum_type: Type[T], value: str) -> T:
|
|
40
|
+
"""Safely get an enum value from a string.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
enum_type: The enum class to get the value from.
|
|
44
|
+
value: The string value to look up in the enum.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The corresponding enum member.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If the value is not found in the enum.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
return enum_type(value)
|
|
54
|
+
except ValueError as e:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Invalid value '{value}' for enum {enum_type.__name__}"
|
|
57
|
+
) from e
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from pdf_anonymizer_core.call_llm import identify_entities_with_llm
|
|
7
|
+
from pdf_anonymizer_core.load_and_extract import load_and_extract_text_from_file
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def anonymize_file(
|
|
11
|
+
file_path: str,
|
|
12
|
+
characters_to_anonymize: int,
|
|
13
|
+
prompt_template: str,
|
|
14
|
+
model_name: str,
|
|
15
|
+
anonymized_entities: Optional[List[str]] = None,
|
|
16
|
+
) -> Tuple[Optional[str], Optional[Dict[str, str]]]:
|
|
17
|
+
"""
|
|
18
|
+
Anonymize a file by processing its text content.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
file_path: Path to the file to anonymize.
|
|
22
|
+
characters_to_anonymize: Number of characters to process in each chunk.
|
|
23
|
+
prompt_template: Template string for the anonymization prompt.
|
|
24
|
+
model_name: Name of the language model to use for anonymization.
|
|
25
|
+
anonymized_entities: A list of entities to anonymize.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A tuple containing the anonymized text and the mapping of original to anonymized entities,
|
|
29
|
+
or (None, None) if processing fails.
|
|
30
|
+
"""
|
|
31
|
+
# File: chunk and convert to text
|
|
32
|
+
file_size = os.path.getsize(file_path)
|
|
33
|
+
text_pages: List[str] = load_and_extract_text_from_file(
|
|
34
|
+
file_path, characters_to_anonymize
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if not text_pages:
|
|
38
|
+
logging.warning("No text could be extracted from the file.")
|
|
39
|
+
return None, None
|
|
40
|
+
|
|
41
|
+
logging.info(f"Extracted text pages: {text_pages[0][:50]} ...")
|
|
42
|
+
extracted_text_size = sum(len(page) for page in text_pages)
|
|
43
|
+
|
|
44
|
+
logging.info(f" - File size: {file_size / 1024:.2f} KB")
|
|
45
|
+
logging.info(f" - Extracted text size: {extracted_text_size / 1024:.2f} KB")
|
|
46
|
+
|
|
47
|
+
# Anonymization:
|
|
48
|
+
anonymized_chunks: List[str] = []
|
|
49
|
+
final_mapping: Dict[str, str] = {}
|
|
50
|
+
placeholder_counts: Dict[str, int] = {}
|
|
51
|
+
base_entity_placeholders: Dict[str, str] = {}
|
|
52
|
+
variation_counters: Dict[str, int] = {}
|
|
53
|
+
|
|
54
|
+
for i, text_page in enumerate(text_pages):
|
|
55
|
+
logging.info(f"Identifying entities in part {i + 1}/{len(text_pages)}...")
|
|
56
|
+
start_time = time.time()
|
|
57
|
+
|
|
58
|
+
all_entities = identify_entities_with_llm(
|
|
59
|
+
text_page, prompt_template, model_name
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
end_time = time.time()
|
|
63
|
+
duration = end_time - start_time
|
|
64
|
+
minutes = int(duration // 60)
|
|
65
|
+
seconds = int(duration % 60)
|
|
66
|
+
logging.info(f" LLM call duration: {minutes}:{seconds:02d}")
|
|
67
|
+
|
|
68
|
+
entities_to_process = all_entities
|
|
69
|
+
if anonymized_entities:
|
|
70
|
+
entities_to_process = [
|
|
71
|
+
e for e in all_entities if e["type"] in anonymized_entities
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
logging.info(
|
|
75
|
+
f"Found {len(all_entities)} total entities. "
|
|
76
|
+
f"Processing {len(entities_to_process)} entities."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Consolidate base forms to handle variations like "John" vs "John Doe"
|
|
80
|
+
base_forms = {
|
|
81
|
+
e.get("base_form") for e in entities_to_process if e.get("base_form")
|
|
82
|
+
}
|
|
83
|
+
sorted_base_forms = sorted(list(base_forms), key=len, reverse=True)
|
|
84
|
+
for entity in entities_to_process:
|
|
85
|
+
base_form = entity.get("base_form")
|
|
86
|
+
if not base_form:
|
|
87
|
+
continue
|
|
88
|
+
for potential_full_form in sorted_base_forms:
|
|
89
|
+
if (
|
|
90
|
+
base_form != potential_full_form
|
|
91
|
+
and base_form in potential_full_form
|
|
92
|
+
):
|
|
93
|
+
entity["base_form"] = potential_full_form
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# Generate placeholders for all entities that need to be processed
|
|
97
|
+
for entity in entities_to_process:
|
|
98
|
+
entity_text = entity["text"]
|
|
99
|
+
entity_type = entity["type"].upper()
|
|
100
|
+
base_form = entity.get("base_form") or entity_text
|
|
101
|
+
|
|
102
|
+
if entity_text in final_mapping:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if base_form not in base_entity_placeholders:
|
|
106
|
+
# New base entity, create main placeholder
|
|
107
|
+
current_count = placeholder_counts.get(entity_type, 0) + 1
|
|
108
|
+
placeholder_counts[entity_type] = current_count
|
|
109
|
+
main_placeholder = f"{entity_type}_{current_count}"
|
|
110
|
+
base_entity_placeholders[base_form] = main_placeholder
|
|
111
|
+
if base_form not in final_mapping:
|
|
112
|
+
final_mapping[base_form] = main_placeholder
|
|
113
|
+
|
|
114
|
+
main_placeholder = base_entity_placeholders[base_form]
|
|
115
|
+
|
|
116
|
+
if entity_text != base_form:
|
|
117
|
+
# It's a variation, create variation placeholder
|
|
118
|
+
current_variation_count = (
|
|
119
|
+
variation_counters.get(main_placeholder, 0) + 1
|
|
120
|
+
)
|
|
121
|
+
variation_counters[main_placeholder] = current_variation_count
|
|
122
|
+
variation_placeholder = (
|
|
123
|
+
f"{main_placeholder}.v_{current_variation_count}"
|
|
124
|
+
)
|
|
125
|
+
final_mapping[entity_text] = variation_placeholder
|
|
126
|
+
else:
|
|
127
|
+
final_mapping[entity_text] = main_placeholder
|
|
128
|
+
|
|
129
|
+
# Sort entities by length descending to replace longer strings first
|
|
130
|
+
entities_to_process.sort(key=lambda e: len(e["text"]), reverse=True)
|
|
131
|
+
|
|
132
|
+
anonymized_text = text_page
|
|
133
|
+
for entity in entities_to_process:
|
|
134
|
+
placeholder = final_mapping.get(entity["text"])
|
|
135
|
+
if placeholder:
|
|
136
|
+
anonymized_text = anonymized_text.replace(entity["text"], placeholder)
|
|
137
|
+
|
|
138
|
+
anonymized_chunks.append(anonymized_text)
|
|
139
|
+
|
|
140
|
+
full_anonymized_text = "\n\n--- Page Break ---\n\n".join(anonymized_chunks)
|
|
141
|
+
|
|
142
|
+
return full_anonymized_text, final_mapping
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pymupdf4llm
|
|
5
|
+
from langchain_text_splitters import (
|
|
6
|
+
MarkdownTextSplitter,
|
|
7
|
+
RecursiveCharacterTextSplitter,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_and_extract_text_from_pdf(
|
|
12
|
+
file_path: str, characters_to_anonymize: int = 100000
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
"""
|
|
15
|
+
Loads a PDF file and extracts text from each page.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
characters_to_anonymize: Number of characters to anonymize in one go.
|
|
19
|
+
file_path (str): The path to the PDF file.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
list: A list of strings, where each string is the text of a page.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
md_text = pymupdf4llm.to_markdown(file_path, show_progress=False)
|
|
26
|
+
splitter = MarkdownTextSplitter(
|
|
27
|
+
chunk_size=characters_to_anonymize, chunk_overlap=0
|
|
28
|
+
)
|
|
29
|
+
docs = splitter.create_documents([md_text])
|
|
30
|
+
return [doc.page_content for doc in docs]
|
|
31
|
+
except FileNotFoundError as e:
|
|
32
|
+
logging.error(f"Error: The file at {file_path} was not found.")
|
|
33
|
+
raise e
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logging.error(f"An error occurred while reading the PDF: {e}")
|
|
36
|
+
raise e
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_and_extract_text_from_file(
|
|
40
|
+
file_path: str, characters_to_anonymize: int = 100000
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
"""
|
|
43
|
+
Loads a file and extracts text, splitting it into chunks.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
file_path (str): The path to the file.
|
|
47
|
+
characters_to_anonymize: Number of characters to process in each chunk.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
list: A list of strings, where each string is a chunk of text.
|
|
51
|
+
"""
|
|
52
|
+
path = Path(file_path)
|
|
53
|
+
file_extension = path.suffix.lower()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
if file_extension == ".pdf":
|
|
57
|
+
return load_and_extract_text_from_pdf(file_path, characters_to_anonymize)
|
|
58
|
+
elif file_extension == ".md":
|
|
59
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
60
|
+
text = f.read()
|
|
61
|
+
splitter = MarkdownTextSplitter(
|
|
62
|
+
chunk_size=characters_to_anonymize, chunk_overlap=0
|
|
63
|
+
)
|
|
64
|
+
docs = splitter.create_documents([text])
|
|
65
|
+
return [doc.page_content for doc in docs]
|
|
66
|
+
elif file_extension == ".txt":
|
|
67
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
68
|
+
text = f.read()
|
|
69
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
70
|
+
chunk_size=characters_to_anonymize, chunk_overlap=0
|
|
71
|
+
)
|
|
72
|
+
docs = splitter.create_documents([text])
|
|
73
|
+
return [doc.page_content for doc in docs]
|
|
74
|
+
else:
|
|
75
|
+
logging.warning(
|
|
76
|
+
f"Unsupported file type: {file_extension}. Treating as plain text."
|
|
77
|
+
)
|
|
78
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
79
|
+
text = f.read()
|
|
80
|
+
splitter = RecursiveCharacterTextSplitter(
|
|
81
|
+
chunk_size=characters_to_anonymize, chunk_overlap=0
|
|
82
|
+
)
|
|
83
|
+
docs = splitter.create_documents([text])
|
|
84
|
+
return [doc.page_content for doc in docs]
|
|
85
|
+
except FileNotFoundError as e:
|
|
86
|
+
logging.error(f"Error: The file at {file_path} was not found.")
|
|
87
|
+
raise e
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logging.error(f"An error occurred while reading the file: {e}")
|
|
90
|
+
raise e
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
prompt_template = """
|
|
2
|
+
You are an expert in identifying Personally Identifiable Information (PII) with high accuracy and contextual understanding.
|
|
3
|
+
Your task is to read the text below and identify all PII entities, including their variations.
|
|
4
|
+
|
|
5
|
+
Instructions:
|
|
6
|
+
1. **Read the text carefully** to understand the context.
|
|
7
|
+
2. **Identify all PII** based on the guidelines below.
|
|
8
|
+
3. **Use contextual awareness.** For example, "Apple" as a fruit should not be identified, but "Apple Inc." as a company should.
|
|
9
|
+
4. **Handle variations.** For each entity identified, you must determine its base form. For example, the base form of "Mary's" is "Mary Smith" if the context refers to a person named Mary Smith. The base form of "Mr. John Doe" is "John Doe".
|
|
10
|
+
5. **Return a single JSON object** with one key: "entities".
|
|
11
|
+
6. The value of "entities" should be a list of JSON objects. Each object represents a PII entity and MUST have the following keys:
|
|
12
|
+
- "text": The exact PII text found in the document.
|
|
13
|
+
- "type": The type of the entity (e.g., PERSON, ORGANIZATION).
|
|
14
|
+
- "base_form": The canonical or base form of the entity.
|
|
15
|
+
|
|
16
|
+
ENTITY TYPES:
|
|
17
|
+
* **PERSON:** Full names, first names, last names, middle names, and their variations (e.g., possessives, titles).
|
|
18
|
+
* **ADDRESS:** Street names, house numbers, city names, state/province names, postal codes, country names.
|
|
19
|
+
* **DATE:** Only birthdates. Do not identify other dates.
|
|
20
|
+
* **PHONE:** Any numerical sequences resembling phone numbers.
|
|
21
|
+
* **EMAIL:** Standard email address formats.
|
|
22
|
+
* **ORGANIZATION:** Names of organizations, businesses, companies.
|
|
23
|
+
* **JOB_TITLE:** Specific roles or positions within organizations.
|
|
24
|
+
* **ID:** Any alphanumeric strings that appear to be account numbers or identifiers.
|
|
25
|
+
* **LOCATION:** Locations that are not full addresses, like cities or landmarks.
|
|
26
|
+
|
|
27
|
+
Example 1:
|
|
28
|
+
Text: "Mr. John Doe from Acme Inc. visited our office in Springfield yesterday. We discussed Mary's project."
|
|
29
|
+
Response:
|
|
30
|
+
{{
|
|
31
|
+
"entities": [
|
|
32
|
+
{{"text": "Mr. John Doe", "type": "PERSON", "base_form": "John Doe"}},
|
|
33
|
+
{{"text": "Acme Inc.", "type": "ORGANIZATION", "base_form": "Acme Inc."}},
|
|
34
|
+
{{"text": "Springfield", "type": "LOCATION", "base_form": "Springfield"}},
|
|
35
|
+
{{"text": "Mary's", "type": "PERSON", "base_form": "Mary"}}
|
|
36
|
+
]
|
|
37
|
+
}}
|
|
38
|
+
|
|
39
|
+
Example 2:
|
|
40
|
+
Text: "We need to review John's latest report about the project for The New York Times."
|
|
41
|
+
Response:
|
|
42
|
+
{{
|
|
43
|
+
"entities": [
|
|
44
|
+
{{"text": "John's", "type": "PERSON", "base_form": "John"}},
|
|
45
|
+
{{"text": "The New York Times", "type": "ORGANIZATION", "base_form": "The New York Times"}}
|
|
46
|
+
]
|
|
47
|
+
}}
|
|
48
|
+
|
|
49
|
+
Text to process:
|
|
50
|
+
---
|
|
51
|
+
{text}
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
Respond with ONLY the JSON object.
|
|
55
|
+
"""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
prompt_template = """
|
|
2
|
+
You are an expert in identifying Personally Identifiable Information (PII).
|
|
3
|
+
Your task is to read the text below and identify all PII entities.
|
|
4
|
+
|
|
5
|
+
Instructions:
|
|
6
|
+
1. Read the text carefully.
|
|
7
|
+
2. Identify all PII, such as names, locations, organizations, phone numbers, email addresses, etc.
|
|
8
|
+
3. Return a single JSON object with one key: "entities".
|
|
9
|
+
4. The value of "entities" should be a list of JSON objects, where each object represents a PII entity and has two keys: "text" (the PII) and "type" (the entity type, e.g., PERSON, ORGANIZATION).
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
Text: "John Doe from Acme Inc. visited our office."
|
|
13
|
+
Response:
|
|
14
|
+
{{
|
|
15
|
+
"entities": [
|
|
16
|
+
{{"text": "John Doe", "type": "PERSON"}},
|
|
17
|
+
{{"text": "Acme Inc.", "type": "ORGANIZATION"}}
|
|
18
|
+
]
|
|
19
|
+
}}
|
|
20
|
+
|
|
21
|
+
Text to process:
|
|
22
|
+
---
|
|
23
|
+
{text}
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
Respond with ONLY the JSON object.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, Tuple
|
|
6
|
+
|
|
7
|
+
_PLACEHOLDER_PATTERN = re.compile(r"^[A-Z_]+_[0-9]+(?:\.v_[0-9]+)?$")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def consolidate_mapping(
|
|
11
|
+
anonymized_text: str, mapping: Dict[str, str]
|
|
12
|
+
) -> Tuple[str, Dict[str, str]]:
|
|
13
|
+
"""
|
|
14
|
+
Consolidates the mapping to ensure one-to-one correspondence and updates the text.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
anonymized_text: The text with anonymized placeholders.
|
|
18
|
+
mapping: The dictionary mapping placeholders to original PII.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A tuple containing the updated anonymized text and the consolidated mapping.
|
|
22
|
+
"""
|
|
23
|
+
# Invert the mapping to find duplicates
|
|
24
|
+
value_to_keys: Dict[str, list] = {}
|
|
25
|
+
for key, value in mapping.items():
|
|
26
|
+
if value not in value_to_keys:
|
|
27
|
+
value_to_keys[value] = []
|
|
28
|
+
value_to_keys[value].append(key)
|
|
29
|
+
|
|
30
|
+
consolidation_map = {}
|
|
31
|
+
consolidated_mapping = mapping.copy()
|
|
32
|
+
|
|
33
|
+
for value, keys in value_to_keys.items():
|
|
34
|
+
if len(keys) > 1:
|
|
35
|
+
canonical_key = keys[0]
|
|
36
|
+
for key_to_replace in keys[1:]:
|
|
37
|
+
consolidation_map[key_to_replace] = canonical_key
|
|
38
|
+
if key_to_replace in consolidated_mapping:
|
|
39
|
+
del consolidated_mapping[key_to_replace]
|
|
40
|
+
|
|
41
|
+
# Update the anonymized text
|
|
42
|
+
for old_key, new_key in consolidation_map.items():
|
|
43
|
+
# Use word boundaries to avoid replacing parts of other words
|
|
44
|
+
anonymized_text = re.sub(
|
|
45
|
+
r"\b" + re.escape(old_key) + r"\b", new_key, anonymized_text
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return anonymized_text, consolidated_mapping
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_results(
|
|
52
|
+
full_anonymized_text: str, final_mapping: dict[str, str], file_path: str
|
|
53
|
+
) -> tuple[str, str]:
|
|
54
|
+
"""
|
|
55
|
+
Save the anonymized text and the mapping to files.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
full_anonymized_text (str): The anonymized text.
|
|
59
|
+
final_mapping (dict[str, str]): Mapping of original text -> placeholder.
|
|
60
|
+
file_path (str): The path to the original file.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
tuple[str, str]: The paths to the anonymized text file and the mapping file.
|
|
64
|
+
"""
|
|
65
|
+
original_path = Path(file_path)
|
|
66
|
+
file_stem = original_path.stem
|
|
67
|
+
file_extension = original_path.suffix.lower()
|
|
68
|
+
|
|
69
|
+
anonymized_dir = "data/anonymized"
|
|
70
|
+
mappings_dir = "data/mappings"
|
|
71
|
+
os.makedirs(anonymized_dir, exist_ok=True)
|
|
72
|
+
os.makedirs(mappings_dir, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
if file_extension == ".pdf":
|
|
75
|
+
output_extension = ".md"
|
|
76
|
+
else:
|
|
77
|
+
output_extension = file_extension
|
|
78
|
+
|
|
79
|
+
anonymized_output_file = (
|
|
80
|
+
f"{anonymized_dir}/{file_stem}.anonymized{output_extension}"
|
|
81
|
+
)
|
|
82
|
+
with open(anonymized_output_file, "w", encoding="utf-8") as f:
|
|
83
|
+
f.write(full_anonymized_text)
|
|
84
|
+
|
|
85
|
+
mapping_file = f"{mappings_dir}/{file_stem}.mapping.json"
|
|
86
|
+
# Persist mapping as placeholder -> original for correct deanonymization
|
|
87
|
+
with open(mapping_file, "w", encoding="utf-8") as f:
|
|
88
|
+
json.dump(final_mapping, f, indent=4)
|
|
89
|
+
|
|
90
|
+
return anonymized_output_file, mapping_file
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def deanonymize_file(
|
|
94
|
+
anonymized_file_path: str, mapping_file_path: str
|
|
95
|
+
) -> tuple[str, str]:
|
|
96
|
+
"""
|
|
97
|
+
Deanonymize a file using a mapping file.
|
|
98
|
+
|
|
99
|
+
The mapping file can be either:
|
|
100
|
+
- placeholder -> original (preferred), or
|
|
101
|
+
- original -> placeholder (legacy). In this case it will be inverted.
|
|
102
|
+
|
|
103
|
+
Variations like "PERSON_1.v_1" will be mapped to the base placeholder's
|
|
104
|
+
original value if only the base (e.g., "PERSON_1") exists in the mapping.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
anonymized_file_path (str): Path to the anonymized file.
|
|
108
|
+
mapping_file_path (str): Path to the mapping file.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A tuple containing the path to the deanonymized file and the statistics file.
|
|
112
|
+
"""
|
|
113
|
+
with open(anonymized_file_path, "r", encoding="utf-8") as f:
|
|
114
|
+
anonymized_text = f.read()
|
|
115
|
+
|
|
116
|
+
with open(mapping_file_path, "r", encoding="utf-8") as f:
|
|
117
|
+
raw_mapping = json.load(f)
|
|
118
|
+
|
|
119
|
+
# Detect mapping direction and normalize to placeholder -> original
|
|
120
|
+
# Heuristic: if most keys look like placeholders (e.g., PERSON_1), treat as placeholder->original
|
|
121
|
+
placeholder_key_pattern = _PLACEHOLDER_PATTERN
|
|
122
|
+
keys_look_like_placeholders = sum(
|
|
123
|
+
1
|
|
124
|
+
for k in raw_mapping.keys()
|
|
125
|
+
if isinstance(k, str) and placeholder_key_pattern.match(k)
|
|
126
|
+
)
|
|
127
|
+
values_look_like_placeholders = sum(
|
|
128
|
+
1
|
|
129
|
+
for v in raw_mapping.values()
|
|
130
|
+
if isinstance(v, str) and placeholder_key_pattern.match(v)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if keys_look_like_placeholders >= values_look_like_placeholders:
|
|
134
|
+
placeholder_to_original = dict(raw_mapping)
|
|
135
|
+
else:
|
|
136
|
+
# Legacy: invert original -> placeholder to placeholder -> original
|
|
137
|
+
placeholder_to_original = {}
|
|
138
|
+
for original, placeholder in raw_mapping.items():
|
|
139
|
+
if isinstance(placeholder, str):
|
|
140
|
+
placeholder_to_original.setdefault(placeholder, original)
|
|
141
|
+
|
|
142
|
+
deanonymized_text = anonymized_text
|
|
143
|
+
used_placeholders = set() # track actual placeholders (including variations) found
|
|
144
|
+
|
|
145
|
+
# Replace placeholders by longest first to avoid partial overlaps
|
|
146
|
+
sorted_placeholders = sorted(placeholder_to_original.keys(), key=len, reverse=True)
|
|
147
|
+
|
|
148
|
+
for base_placeholder in sorted_placeholders:
|
|
149
|
+
original_value = placeholder_to_original[base_placeholder]
|
|
150
|
+
# Match base and its variations: PERSON_1 and PERSON_1.v_1, PERSON_1.v_2, ...
|
|
151
|
+
pattern = re.compile(rf"\b{re.escape(base_placeholder)}(?:\.v_\d+)?\b")
|
|
152
|
+
|
|
153
|
+
# Record any matches before substitution
|
|
154
|
+
matches = set(pattern.findall(deanonymized_text))
|
|
155
|
+
if matches:
|
|
156
|
+
used_placeholders.update(matches)
|
|
157
|
+
deanonymized_text = pattern.sub(original_value, deanonymized_text)
|
|
158
|
+
|
|
159
|
+
# Gather stats
|
|
160
|
+
all_placeholders_in_text = set(
|
|
161
|
+
re.findall(r"[A-Z_]+_[0-9]+(?:\.v_[0-9]+)?", anonymized_text)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
not_found_mappings = sorted(list(all_placeholders_in_text - used_placeholders))
|
|
165
|
+
|
|
166
|
+
# Unused mappings: base placeholders that never occurred (neither base nor any variation)
|
|
167
|
+
used_bases = {p.split(".v_")[0] for p in used_placeholders}
|
|
168
|
+
unused_mappings = sorted([p for p in sorted_placeholders if p not in used_bases])
|
|
169
|
+
|
|
170
|
+
anonymized_path = Path(anonymized_file_path)
|
|
171
|
+
file_stem = anonymized_path.name.replace(f".anonymized{anonymized_path.suffix}", "")
|
|
172
|
+
output_extension = anonymized_path.suffix
|
|
173
|
+
|
|
174
|
+
deanonymized_dir = "data/deanonymized"
|
|
175
|
+
stats_dir = "data/stats"
|
|
176
|
+
os.makedirs(deanonymized_dir, exist_ok=True)
|
|
177
|
+
os.makedirs(stats_dir, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
deanonymized_file = f"{deanonymized_dir}/{file_stem}.deanonymized{output_extension}"
|
|
180
|
+
with open(deanonymized_file, "w", encoding="utf-8") as f:
|
|
181
|
+
f.write(deanonymized_text)
|
|
182
|
+
|
|
183
|
+
stats_file = f"{stats_dir}/{file_stem}.deanonymization_stat.json"
|
|
184
|
+
stats = {
|
|
185
|
+
"anonymized_file": anonymized_file_path,
|
|
186
|
+
"mapping_file": mapping_file_path,
|
|
187
|
+
"deanonymized_file": deanonymized_file,
|
|
188
|
+
"unused_mappings": unused_mappings,
|
|
189
|
+
"not_found_mappings": not_found_mappings,
|
|
190
|
+
}
|
|
191
|
+
with open(stats_file, "w", encoding="utf-8") as f:
|
|
192
|
+
json.dump(stats, f, indent=4)
|
|
193
|
+
|
|
194
|
+
return deanonymized_file, stats_file
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf-anonymizer-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A core library to anonymize PDF, Markdown, and plain text files using LLMs.
|
|
5
|
+
Author-email: Leonid Ganeline <leo.gan.57@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: repository, https://github.com/leo-gan/anonymizer
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
Requires-Dist: pymupdf4llm
|
|
12
|
+
Requires-Dist: langchain-text-splitters
|
|
13
|
+
Requires-Dist: google-genai
|
|
14
|
+
Requires-Dist: ollama
|
|
15
|
+
|
|
16
|
+
# PDF Anonymizer Core
|
|
17
|
+
|
|
18
|
+
This package provides the core functionality for the PDF/Text anonymizer, including text extraction, LLM-driven anonymization, and deanonymization logic. It is used by `pdf-anonymizer-cli`.
|
|
19
|
+
|
|
20
|
+
## Installation for Development
|
|
21
|
+
|
|
22
|
+
This project uses `uv` and is structured as a monorepo. To install the necessary dependencies for development, run the following command from the root of the repository:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# From the repository root
|
|
26
|
+
uv sync
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This will install the `pdf-anonymizer-core` package in editable mode.
|
|
30
|
+
|
|
31
|
+
## Environment Variables
|
|
32
|
+
|
|
33
|
+
The core library itself does not load `.env` files. Environment variables must be loaded by the application that uses this library (e.g., `pdf-anonymizer-cli`) or set in your shell.
|
|
34
|
+
|
|
35
|
+
- `GOOGLE_API_KEY`: Required when using Google's Gemini models.
|
|
36
|
+
- `OLLAMA_HOST`: Optional, defaults to `http://localhost:11434` when using local Ollama models.
|
|
37
|
+
|
|
38
|
+
## API Usage
|
|
39
|
+
|
|
40
|
+
### `anonymize_file()`
|
|
41
|
+
|
|
42
|
+
Anonymizes a single file and returns the anonymized text and a mapping of original entities to their placeholders.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pdf_anonymizer_core.core import anonymize_file
|
|
46
|
+
from pdf_anonymizer_core.prompts import detailed
|
|
47
|
+
|
|
48
|
+
# Example of programmatic usage
|
|
49
|
+
text, mapping = anonymize_file(
|
|
50
|
+
file_path="/path/to/file.pdf",
|
|
51
|
+
prompt_template=detailed.prompt_template,
|
|
52
|
+
model_name="gemini-2.5-flash"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if text and mapping:
|
|
56
|
+
print("Anonymized Text:", text)
|
|
57
|
+
print("Mapping:", mapping)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `deanonymize_file()`
|
|
61
|
+
|
|
62
|
+
Reverts anonymization using a mapping file.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pdf_anonymizer_core.utils import deanonymize_file
|
|
66
|
+
|
|
67
|
+
# Assumes you have an anonymized file and a mapping file
|
|
68
|
+
deanonymized_text, stats = deanonymize_file(
|
|
69
|
+
anonymized_file="path/to/anonymized.md",
|
|
70
|
+
mapping_file="path/to/mapping.json"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if deanonymized_text:
|
|
74
|
+
print("Deanonymized Text:", deanonymized_text)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Configuration
|
|
78
|
+
|
|
79
|
+
You can import default configurations and available models from the `conf` module.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from pdf_anonymizer_core.conf import (
|
|
83
|
+
DEFAULT_MODEL_NAME,
|
|
84
|
+
ModelName,
|
|
85
|
+
PromptEnum,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
print(f"Default model: {DEFAULT_MODEL_NAME}")
|
|
89
|
+
print(f"Available Google models: {[m.value for m in ModelName if 'gemini' in m.value]}")
|
|
90
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/pdf_anonymizer_core/__init__.py
|
|
4
|
+
src/pdf_anonymizer_core/call_llm.py
|
|
5
|
+
src/pdf_anonymizer_core/conf.py
|
|
6
|
+
src/pdf_anonymizer_core/core.py
|
|
7
|
+
src/pdf_anonymizer_core/load_and_extract.py
|
|
8
|
+
src/pdf_anonymizer_core/utils.py
|
|
9
|
+
src/pdf_anonymizer_core.egg-info/PKG-INFO
|
|
10
|
+
src/pdf_anonymizer_core.egg-info/SOURCES.txt
|
|
11
|
+
src/pdf_anonymizer_core.egg-info/dependency_links.txt
|
|
12
|
+
src/pdf_anonymizer_core.egg-info/requires.txt
|
|
13
|
+
src/pdf_anonymizer_core.egg-info/top_level.txt
|
|
14
|
+
src/pdf_anonymizer_core/prompts/__init__.py
|
|
15
|
+
src/pdf_anonymizer_core/prompts/detailed.py
|
|
16
|
+
src/pdf_anonymizer_core/prompts/simple.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pdf_anonymizer_core
|