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.
@@ -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"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
@@ -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,5 @@
1
+ python-dotenv
2
+ pymupdf4llm
3
+ langchain-text-splitters
4
+ google-genai
5
+ ollama
@@ -0,0 +1 @@
1
+ pdf_anonymizer_core