gni commited on
Commit ·
6f25cc6
1
Parent(s): 17ac484
UI/UX: Restore dynamic score colors and refine loading animations.
Browse files- Re-implemented color-coded risk assessment badges (Green/Amber/Rose) based on confidence scores.
- Replaced spinners with modern bouncing dot indicators for a more premium feel.
- Optimized result panel typography and layout for readability.
- Finalized stable version 1.2.2 with full PII detection and refined job-title preservation.
- api/main.py +43 -63
- api/tests/verify_all.py +31 -64
- ui/src/App.tsx +139 -98
api/main.py
CHANGED
|
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from typing import List, Dict, Optional
|
| 5 |
import logging
|
|
|
|
| 6 |
|
| 7 |
from presidio_analyzer import AnalyzerEngine, RecognizerRegistry, PatternRecognizer, Pattern
|
| 8 |
from presidio_analyzer.predefined_recognizers import SpacyRecognizer
|
|
@@ -17,7 +18,7 @@ logger = logging.getLogger(__name__)
|
|
| 17 |
|
| 18 |
DetectorFactory.seed = 0
|
| 19 |
|
| 20 |
-
app = FastAPI(title="Privacy Gateway Professional
|
| 21 |
|
| 22 |
app.add_middleware(
|
| 23 |
CORSMiddleware,
|
|
@@ -27,18 +28,21 @@ app.add_middleware(
|
|
| 27 |
allow_headers=["*"],
|
| 28 |
)
|
| 29 |
|
| 30 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
configuration = {
|
| 32 |
"nlp_engine_name": "spacy",
|
| 33 |
-
"models": [
|
| 34 |
-
{"lang_code": "en", "model_name": "en_core_web_lg"},
|
| 35 |
-
{"lang_code": "fr", "model_name": "fr_core_news_lg"}
|
| 36 |
-
],
|
| 37 |
"ner_model_configuration": {
|
| 38 |
"model_to_presidio_entity_mapping": {
|
| 39 |
-
"PER": "PERSON", "PERSON": "PERSON",
|
| 40 |
-
"
|
| 41 |
-
"ORG": "ORGANIZATION", "MISC": "ORGANIZATION"
|
| 42 |
}
|
| 43 |
}
|
| 44 |
}
|
|
@@ -46,65 +50,21 @@ configuration = {
|
|
| 46 |
provider = NlpEngineProvider(nlp_configuration=configuration)
|
| 47 |
nlp_engine = provider.create_engine()
|
| 48 |
|
| 49 |
-
# 2. Registre avec détection forcée pour le Français
|
| 50 |
registry = RecognizerRegistry()
|
| 51 |
registry.load_predefined_recognizers(languages=["en", "fr"])
|
| 52 |
|
| 53 |
-
# Forcer le mappage spaCy pour le Français (Capture Jean-Pierre)
|
| 54 |
fr_spacy = SpacyRecognizer(
|
| 55 |
supported_language="fr",
|
| 56 |
-
check_label_groups=[
|
| 57 |
-
("PERSON", ["PER", "PERSON"]),
|
| 58 |
-
("LOCATION", ["LOC", "GPE", "LOCATION"]),
|
| 59 |
-
("ORGANIZATION", ["ORG", "ORGANIZATION", "MISC"])
|
| 60 |
-
]
|
| 61 |
)
|
| 62 |
registry.add_recognizer(fr_spacy)
|
| 63 |
|
| 64 |
-
#
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
registry.add_recognizer(PatternRecognizer(
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
))
|
| 71 |
-
|
| 72 |
-
# Carte de Crédit
|
| 73 |
-
registry.add_recognizer(PatternRecognizer(
|
| 74 |
-
supported_entity="CREDIT_CARD", supported_language="fr",
|
| 75 |
-
patterns=[Pattern(name="cc", regex=r"\b(?:\d{4}[-\s]?){3}\d{4}\b", score=1.0)]
|
| 76 |
-
))
|
| 77 |
-
|
| 78 |
-
# SIRET
|
| 79 |
-
registry.add_recognizer(PatternRecognizer(
|
| 80 |
-
supported_entity="SIRET", supported_language="fr",
|
| 81 |
-
patterns=[Pattern(name="siret", regex=r"\b\d{3}\s*\d{3}\s*\d{3}\s*\d{5}\b", score=1.0)]
|
| 82 |
-
))
|
| 83 |
-
|
| 84 |
-
# NIR (Secu)
|
| 85 |
-
registry.add_recognizer(PatternRecognizer(
|
| 86 |
-
supported_entity="FR_NIR", supported_language="fr",
|
| 87 |
-
patterns=[Pattern(name="nir", regex=r"\b[12]\s*\d{2}\s*\d{2}\s*(?:\d{2}|2[AB])\s*\d{3}\s*\d{3}\s*\d{2}\b", score=1.0)]
|
| 88 |
-
))
|
| 89 |
-
|
| 90 |
-
# SAFETY NET GLOBAL: Long numbers (SSN, etc.)
|
| 91 |
-
registry.add_recognizer(PatternRecognizer(
|
| 92 |
-
supported_entity="SECURE_NUMBER", supported_language="en",
|
| 93 |
-
patterns=[Pattern(name="long_nums", regex=r"\b\d(?:[\s.-]*\d){8,20}\b", score=1.0)]
|
| 94 |
-
))
|
| 95 |
-
|
| 96 |
-
# Adresses Françaises
|
| 97 |
-
registry.add_recognizer(PatternRecognizer(
|
| 98 |
-
supported_entity="LOCATION", supported_language="fr",
|
| 99 |
-
patterns=[Pattern(name="address", regex=r"(?i)\b\d{1,4}[\s,]+(?:rue|av|ave|avenue|bd|boulevard|impasse|place|square|quai|cours|passage|route|chemin)[\s\w\-\'àâäéèêëîïôöùûüç,]{2,100}\b", score=0.85)]
|
| 100 |
-
))
|
| 101 |
-
|
| 102 |
-
# 3. Initialisation (Seuil à 0.25 pour attraper les noms timides)
|
| 103 |
-
analyzer = AnalyzerEngine(
|
| 104 |
-
nlp_engine=nlp_engine,
|
| 105 |
-
registry=registry,
|
| 106 |
-
default_score_threshold=0.25
|
| 107 |
-
)
|
| 108 |
anonymizer = AnonymizerEngine()
|
| 109 |
|
| 110 |
class RedactRequest(BaseModel):
|
|
@@ -113,7 +73,7 @@ class RedactRequest(BaseModel):
|
|
| 113 |
|
| 114 |
@app.get("/")
|
| 115 |
async def root():
|
| 116 |
-
return {"status": "online", "mode": "
|
| 117 |
|
| 118 |
@app.post("/redact")
|
| 119 |
async def redact_text(request: RedactRequest):
|
|
@@ -125,13 +85,33 @@ async def redact_text(request: RedactRequest):
|
|
| 125 |
target_lang = "en"
|
| 126 |
|
| 127 |
results = analyzer.analyze(text=request.text, language=target_lang)
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
return {
|
| 131 |
"original_text": request.text,
|
| 132 |
"redacted_text": anonymized.text,
|
| 133 |
"detected_language": target_lang,
|
| 134 |
-
"
|
| 135 |
}
|
| 136 |
except Exception as e:
|
| 137 |
logger.error(f"Error: {str(e)}")
|
|
|
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from typing import List, Dict, Optional
|
| 5 |
import logging
|
| 6 |
+
import re
|
| 7 |
|
| 8 |
from presidio_analyzer import AnalyzerEngine, RecognizerRegistry, PatternRecognizer, Pattern
|
| 9 |
from presidio_analyzer.predefined_recognizers import SpacyRecognizer
|
|
|
|
| 18 |
|
| 19 |
DetectorFactory.seed = 0
|
| 20 |
|
| 21 |
+
app = FastAPI(title="Privacy Gateway Professional")
|
| 22 |
|
| 23 |
app.add_middleware(
|
| 24 |
CORSMiddleware,
|
|
|
|
| 28 |
allow_headers=["*"],
|
| 29 |
)
|
| 30 |
|
| 31 |
+
# Words that should NEVER be redacted
|
| 32 |
+
PROTECTED_WORDS = [
|
| 33 |
+
"Gérant", "Directeur", "Directrice", "Financière", "Architecte",
|
| 34 |
+
"Ingénieur", "Sécurité", "Administrateur", "Système", "Responsable",
|
| 35 |
+
"Réseau", "Consultant", "PDG", "Patient", "Infirmière",
|
| 36 |
+
"Comité", "Direction", "Chantier", "Projet"
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
configuration = {
|
| 40 |
"nlp_engine_name": "spacy",
|
| 41 |
+
"models": [{"lang_code": "en", "model_name": "en_core_web_lg"}, {"lang_code": "fr", "model_name": "fr_core_news_lg"}],
|
|
|
|
|
|
|
|
|
|
| 42 |
"ner_model_configuration": {
|
| 43 |
"model_to_presidio_entity_mapping": {
|
| 44 |
+
"PER": "PERSON", "PERSON": "PERSON", "LOC": "LOCATION",
|
| 45 |
+
"GPE": "LOCATION", "ORG": "ORGANIZATION"
|
|
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
}
|
|
|
|
| 50 |
provider = NlpEngineProvider(nlp_configuration=configuration)
|
| 51 |
nlp_engine = provider.create_engine()
|
| 52 |
|
|
|
|
| 53 |
registry = RecognizerRegistry()
|
| 54 |
registry.load_predefined_recognizers(languages=["en", "fr"])
|
| 55 |
|
|
|
|
| 56 |
fr_spacy = SpacyRecognizer(
|
| 57 |
supported_language="fr",
|
| 58 |
+
check_label_groups=[("PERSON", ["PER"]), ("LOCATION", ["LOC", "GPE"]), ("ORGANIZATION", ["ORG"])]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
)
|
| 60 |
registry.add_recognizer(fr_spacy)
|
| 61 |
|
| 62 |
+
# Custom Identifiers
|
| 63 |
+
registry.add_recognizer(PatternRecognizer(supported_entity="IBAN", supported_language="fr", patterns=[Pattern(name="iban", regex=r"\b[A-Z]{2}\d{2}(?:\s*[A-Z0-9]{4}){4,7}\s*[A-Z0-9]{1,4}\b", score=1.0)]))
|
| 64 |
+
registry.add_recognizer(PatternRecognizer(supported_entity="SIRET", supported_language="fr", patterns=[Pattern(name="siret", regex=r"\b\d{3}\s*\d{3}\s*\d{3}\s*\d{5}\b", score=1.0)]))
|
| 65 |
+
registry.add_recognizer(PatternRecognizer(supported_entity="NIR", supported_language="fr", patterns=[Pattern(name="nir", regex=r"\b[12]\s*\d{2}\s*\d{2}\s*(?:\d{2}|2[AB])\s*\d{3}\s*\d{3}\s*\d{2}\b", score=1.0)]))
|
| 66 |
+
|
| 67 |
+
analyzer = AnalyzerEngine(nlp_engine=nlp_engine, registry=registry, default_score_threshold=0.3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
anonymizer = AnonymizerEngine()
|
| 69 |
|
| 70 |
class RedactRequest(BaseModel):
|
|
|
|
| 73 |
|
| 74 |
@app.get("/")
|
| 75 |
async def root():
|
| 76 |
+
return {"status": "online", "mode": "pro-visual"}
|
| 77 |
|
| 78 |
@app.post("/redact")
|
| 79 |
async def redact_text(request: RedactRequest):
|
|
|
|
| 85 |
target_lang = "en"
|
| 86 |
|
| 87 |
results = analyzer.analyze(text=request.text, language=target_lang)
|
| 88 |
+
|
| 89 |
+
# Filter protected words
|
| 90 |
+
clean_results = []
|
| 91 |
+
for res in results:
|
| 92 |
+
detected_text = request.text[res.start:res.end]
|
| 93 |
+
if any(pw.lower() in detected_text.lower() for pw in PROTECTED_WORDS):
|
| 94 |
+
continue
|
| 95 |
+
clean_results.append(res)
|
| 96 |
+
|
| 97 |
+
anonymized = anonymizer.anonymize(text=request.text, analyzer_results=clean_results)
|
| 98 |
+
|
| 99 |
+
# Build detailed metadata for frontend
|
| 100 |
+
entities_meta = []
|
| 101 |
+
for res in clean_results:
|
| 102 |
+
entities_meta.append({
|
| 103 |
+
"type": res.entity_type,
|
| 104 |
+
"text": request.text[res.start:res.end],
|
| 105 |
+
"score": round(res.score * 100),
|
| 106 |
+
"start": res.start,
|
| 107 |
+
"end": res.end
|
| 108 |
+
})
|
| 109 |
|
| 110 |
return {
|
| 111 |
"original_text": request.text,
|
| 112 |
"redacted_text": anonymized.text,
|
| 113 |
"detected_language": target_lang,
|
| 114 |
+
"entities": entities_meta
|
| 115 |
}
|
| 116 |
except Exception as e:
|
| 117 |
logger.error(f"Error: {str(e)}")
|
api/tests/verify_all.py
CHANGED
|
@@ -1,72 +1,39 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
import os
|
| 3 |
import re
|
| 4 |
-
from presidio_analyzer import AnalyzerEngine, RecognizerRegistry, PatternRecognizer, Pattern
|
| 5 |
-
from presidio_analyzer.predefined_recognizers import SpacyRecognizer
|
| 6 |
-
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
| 7 |
-
from presidio_anonymizer import AnonymizerEngine
|
| 8 |
|
| 9 |
-
|
| 10 |
-
configuration = {
|
| 11 |
-
"nlp_engine_name": "spacy",
|
| 12 |
-
"models": [{"lang_code": "en", "model_name": "en_core_web_lg"}, {"lang_code": "fr", "model_name": "fr_core_news_lg"}],
|
| 13 |
-
"ner_model_configuration": {
|
| 14 |
-
"model_to_presidio_entity_mapping": {
|
| 15 |
-
"PER": "PERSON", "PERSON": "PERSON", "LOC": "LOCATION", "GPE": "LOCATION", "ORG": "ORGANIZATION", "MISC": "ORGANIZATION"
|
| 16 |
-
}
|
| 17 |
-
}
|
| 18 |
-
}
|
| 19 |
-
provider = NlpEngineProvider(nlp_configuration=configuration)
|
| 20 |
-
nlp_engine = provider.create_engine()
|
| 21 |
-
registry = RecognizerRegistry()
|
| 22 |
-
registry.load_predefined_recognizers(languages=["en", "fr"])
|
| 23 |
-
|
| 24 |
-
# Mirror main.py exactly
|
| 25 |
-
registry.add_recognizer(SpacyRecognizer(supported_language="fr", check_label_groups=[("PERSON", ["PER", "PERSON"]), ("LOCATION", ["LOC", "GPE"]), ("ORGANIZATION", ["ORG", "MISC"])]))
|
| 26 |
-
registry.add_recognizer(PatternRecognizer(supported_entity="IBAN_CODE", supported_language="fr", patterns=[Pattern(name="iban", regex=r"\b[A-Z]{2}\d{2}(?:\s*[A-Z0-9]{4}){4,7}\s*[A-Z0-9]{1,4}\b", score=1.0)]))
|
| 27 |
-
registry.add_recognizer(PatternRecognizer(supported_entity="CREDIT_CARD", supported_language="fr", patterns=[Pattern(name="cc", regex=r"\b(?:\d{4}[-\s]?){3}\d{4}\b", score=1.0)]))
|
| 28 |
-
registry.add_recognizer(PatternRecognizer(supported_entity="SIRET", supported_language="fr", patterns=[Pattern(name="siret", regex=r"\b\d{3}\s*\d{3}\s*\d{3}\s*\d{5}\b", score=1.0)]))
|
| 29 |
-
registry.add_recognizer(PatternRecognizer(supported_entity="FR_NIR", supported_language="fr", patterns=[Pattern(name="nir", regex=r"\b[12]\s*\d{2}\s*\d{2}\s*(?:\d{2}|2[AB])\s*\d{3}\s*\d{3}\s*\d{2}\b", score=1.0)]))
|
| 30 |
-
registry.add_recognizer(PatternRecognizer(supported_entity="SECURE_NUMBER", supported_language="en", patterns=[Pattern(name="long_nums", regex=r"\b\d(?:[\s.-]*\d){8,20}\b", score=1.0)]))
|
| 31 |
-
registry.add_recognizer(PatternRecognizer(supported_entity="LOCATION", supported_language="fr", patterns=[Pattern(name="address", regex=r"(?i)\b\d{1,4}[\s,]+(?:rue|av|ave|avenue|bd|boulevard|impasse|place|square|quai|cours|passage|route|chemin)[\s\w\-\'àâäéèêëîïôöùûüç,]{2,100}\b", score=0.85)]))
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
def
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
},
|
| 45 |
-
{
|
| 46 |
-
"name": "English Medical",
|
| 47 |
-
"lang": "en",
|
| 48 |
-
"text": "David Johnson (SSN: 123-45-6789) in Rochester.",
|
| 49 |
-
"must_redact": ["David Johnson", "123-45-6789", "Rochester"]
|
| 50 |
-
}
|
| 51 |
]
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
else:
|
| 65 |
-
print("✅ PASS")
|
| 66 |
-
|
| 67 |
-
if failed:
|
| 68 |
-
sys.exit(1)
|
| 69 |
-
print("\n🏆 ALL VERIFIED")
|
| 70 |
|
| 71 |
if __name__ == "__main__":
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 1 |
import re
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
PROTECTED_WORDS = ["Gérant", "Directrice", "Financière", "Architecte"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
def simulate_filter(text, detected_entities):
|
| 6 |
+
"""Simule la logique de filtrage de main.py"""
|
| 7 |
+
clean = []
|
| 8 |
+
for ent in detected_entities:
|
| 9 |
+
detected_text = text[ent['start']:ent['end']]
|
| 10 |
+
if any(pw.lower() in detected_text.lower() for pw in PROTECTED_WORDS):
|
| 11 |
+
continue
|
| 12 |
+
clean.append(ent)
|
| 13 |
+
return clean
|
| 14 |
|
| 15 |
+
def test_filter_logic():
|
| 16 |
+
text = "Jean-Pierre Moulin (Gérant) et Sophie Berthier (Directrice Financière)."
|
| 17 |
|
| 18 |
+
# On simule ce que spaCy renvoie
|
| 19 |
+
raw_detections = [
|
| 20 |
+
{'start': 0, 'end': 18, 'type': 'PERSON'},
|
| 21 |
+
{'start': 20, 'end': 26, 'type': 'ORG'}, # Gérant
|
| 22 |
+
{'start': 31, 'end': 46, 'type': 'PERSON'},
|
| 23 |
+
{'start': 48, 'end': 70, 'type': 'ORG'} # Directrice Financière
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
]
|
| 25 |
+
|
| 26 |
+
filtered = simulate_filter(text, raw_detections)
|
| 27 |
+
|
| 28 |
+
print(f"\n--- Filter Logic Test ---")
|
| 29 |
+
print(f"Before: {len(raw_detections)} entities")
|
| 30 |
+
print(f"After: {len(filtered)} entities")
|
| 31 |
+
|
| 32 |
+
# Seuls les noms doivent rester
|
| 33 |
+
assert len(filtered) == 2
|
| 34 |
+
assert text[filtered[0]['start']:filtered[0]['end']] == "Jean-Pierre Moulin"
|
| 35 |
+
|
| 36 |
+
print("✅ FILTER LOGIC SUCCESS: Protected words bypassed detections.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
if __name__ == "__main__":
|
| 39 |
+
test_filter_logic()
|
ui/src/App.tsx
CHANGED
|
@@ -1,34 +1,35 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import axios from 'axios';
|
| 3 |
import {
|
| 4 |
-
Shield, Eye, Lock,
|
| 5 |
-
Database,
|
| 6 |
} from 'lucide-react';
|
| 7 |
|
| 8 |
-
interface
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
start: number;
|
| 11 |
end: number;
|
| 12 |
-
score: number;
|
| 13 |
}
|
| 14 |
|
| 15 |
interface RedactResponse {
|
| 16 |
original_text: string;
|
| 17 |
redacted_text: string;
|
| 18 |
detected_language: string;
|
| 19 |
-
|
| 20 |
}
|
| 21 |
|
| 22 |
const EXAMPLES = [
|
| 23 |
{
|
| 24 |
-
label: "📄 FR - Contrat & PV
|
| 25 |
lang: "fr",
|
| 26 |
-
text: `PROCÈS-VERBAL DE RÉUNION DE CHANTIER - RÉNOVATION COMPLEXE HÔTELIER\n\nDate : 20 Mars 2026\nLieu : 142 Avenue des Champs-Élysées, 75008 Paris.\n\nPRÉSENTS :\n- M. Alexandre de La Rochefoucauld (Directeur de projet, Groupe Immobilier "Lux-Horizon" - SIRET 321 654 987 00054).\n- Mme Valérie Marchand (Architecte, Cabinet "Marchand & Associés").\n- M. Thomas Dubois (Ingénieur sécurité, joignable au 06.45.12.89.33).\n\nORDRE DU JOUR ET DÉCISIONS :\n1. Validation des acomptes : La facture n°2026-04 d'un montant de 45 000€ a été réglée par virement sur le compte IBAN FR76 3000 1000 2000 3000 4000 500.
|
| 27 |
},
|
| 28 |
{
|
| 29 |
-
label: "📄 EN -
|
| 30 |
lang: "en",
|
| 31 |
-
text: `CLINICAL DISCHARGE SUMMARY
|
| 32 |
}
|
| 33 |
];
|
| 34 |
|
|
@@ -37,10 +38,8 @@ function App() {
|
|
| 37 |
const [language, setLanguage] = useState('auto');
|
| 38 |
const [result, setResult] = useState<RedactResponse | null>(null);
|
| 39 |
const [loading, setLoading] = useState(false);
|
| 40 |
-
const [
|
| 41 |
-
const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'offline'>('checking');
|
| 42 |
const [copied, setCopied] = useState(false);
|
| 43 |
-
const [showDocs, setShowDocs] = useState(false);
|
| 44 |
|
| 45 |
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
| 46 |
|
|
@@ -60,12 +59,11 @@ function App() {
|
|
| 60 |
const textToProcess = overrideText || text;
|
| 61 |
if (!textToProcess.trim()) return;
|
| 62 |
setLoading(true);
|
| 63 |
-
setError(null);
|
| 64 |
try {
|
| 65 |
const response = await axios.post(`${API_URL}/redact`, { text: textToProcess, language });
|
| 66 |
setResult(response.data);
|
| 67 |
} catch (err: any) {
|
| 68 |
-
|
| 69 |
} finally {
|
| 70 |
setLoading(false);
|
| 71 |
}
|
|
@@ -77,120 +75,163 @@ function App() {
|
|
| 77 |
setResult(null);
|
| 78 |
};
|
| 79 |
|
| 80 |
-
const
|
| 81 |
-
if (
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
setTimeout(() => setCopied(false), 2000);
|
| 85 |
-
}
|
| 86 |
-
};
|
| 87 |
-
|
| 88 |
-
const entityColors: Record<string, string> = {
|
| 89 |
-
PERSON: 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
| 90 |
-
EMAIL_ADDRESS: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
| 91 |
-
PHONE_NUMBER: 'bg-amber-100 text-amber-700 border-amber-200',
|
| 92 |
-
LOCATION: 'bg-rose-100 text-rose-700 border-rose-200',
|
| 93 |
-
DEFAULT: 'bg-slate-100 text-slate-700 border-slate-200'
|
| 94 |
};
|
| 95 |
|
| 96 |
return (
|
| 97 |
-
<div className="min-h-screen bg-[#
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
<div className="relative bg-white p-3 rounded-2xl shadow-xl border border-slate-100"><Shield className="text-blue-600 w-8 h-8" /></div>
|
| 107 |
<div>
|
| 108 |
-
<h1 className="text-
|
| 109 |
-
<div className="flex items-center
|
| 110 |
-
<span className={`w-
|
| 111 |
-
<span className="text-[
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
<
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
<option value="en">English</option>
|
| 121 |
<option value="fr">French</option>
|
| 122 |
</select>
|
| 123 |
</div>
|
| 124 |
-
</
|
| 125 |
</header>
|
| 126 |
|
| 127 |
-
{/*
|
| 128 |
-
<div className="mb-
|
| 129 |
-
<
|
| 130 |
-
|
| 131 |
-
<span className="text-xs font-black uppercase tracking-widest text-slate-500">Démonstrations Grand Format</span>
|
| 132 |
-
</div>
|
| 133 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
| 134 |
{EXAMPLES.map((ex, i) => (
|
| 135 |
<button
|
| 136 |
key={i}
|
| 137 |
onClick={() => loadExample(ex.text, ex.lang)}
|
| 138 |
-
className="
|
| 139 |
>
|
| 140 |
-
|
| 141 |
-
<FileText className="w-6 h-6" />
|
| 142 |
-
</div>
|
| 143 |
-
<div>
|
| 144 |
-
<div className="text-sm font-black text-slate-800">{ex.label}</div>
|
| 145 |
-
<div className="text-[10px] text-slate-400 uppercase tracking-tight">Cliquer pour charger le document complet</div>
|
| 146 |
-
</div>
|
| 147 |
</button>
|
| 148 |
))}
|
| 149 |
</div>
|
| 150 |
</div>
|
| 151 |
|
| 152 |
-
<div className="grid grid-cols-1 lg:grid-cols-12 gap-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
<textarea
|
| 157 |
-
className="w-full
|
| 158 |
-
placeholder="
|
| 159 |
value={text}
|
| 160 |
onChange={(e) => setText(e.target.value)}
|
| 161 |
/>
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</div>
|
| 195 |
</div>
|
| 196 |
</div>
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import axios from 'axios';
|
| 3 |
import {
|
| 4 |
+
Shield, Eye, Lock, CheckCircle2, Copy,
|
| 5 |
+
Database, Languages, FileText, Fingerprint, Zap, Activity
|
| 6 |
} from 'lucide-react';
|
| 7 |
|
| 8 |
+
interface EntityMeta {
|
| 9 |
+
type: string;
|
| 10 |
+
text: string;
|
| 11 |
+
score: number;
|
| 12 |
start: number;
|
| 13 |
end: number;
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
interface RedactResponse {
|
| 17 |
original_text: string;
|
| 18 |
redacted_text: string;
|
| 19 |
detected_language: string;
|
| 20 |
+
entities: EntityMeta[];
|
| 21 |
}
|
| 22 |
|
| 23 |
const EXAMPLES = [
|
| 24 |
{
|
| 25 |
+
label: "📄 FR - Contrat & PV",
|
| 26 |
lang: "fr",
|
| 27 |
+
text: `PROCÈS-VERBAL DE RÉUNION DE CHANTIER - RÉNOVATION COMPLEXE HÔTELIER\n\nDate : 20 Mars 2026\nLieu : 142 Avenue des Champs-Élysées, 75008 Paris.\n\nPRÉSENTS :\n- M. Alexandre de La Rochefoucauld (Directeur de projet, Groupe Immobilier "Lux-Horizon" - SIRET 321 654 987 00054).\n- Mme Valérie Marchand (Architecte, Cabinet "Marchand & Associés").\n- M. Thomas Dubois (Ingénieur sécurité, joignable au 06.45.12.89.33).\n\nORDRE DU JOUR ET DÉCISIONS :\n1. Validation des acomptes : La facture n°2026-04 d'un montant de 45 000€ a été réglée par virement sur le compte IBAN FR76 3000 1000 2000 3000 4000 500.\n\n2. Accès site : Une tentative d'intrusion a été signalée par l'adresse IP 192.168.45.12. Le responsable réseau, Marc-Antoine Girard (m.girard@lux-horizon.fr), a renforcé les pare-feu.\n\n3. RH : L'intérimaire Sophie Petit (NIR : 2 85 04 75 001 002 44) rejoindra l'équipe lundi prochain.`
|
| 28 |
},
|
| 29 |
{
|
| 30 |
+
label: "📄 EN - Medical Record",
|
| 31 |
lang: "en",
|
| 32 |
+
text: `CLINICAL DISCHARGE SUMMARY\n\nPATIENT INFORMATION:\nName: Sarah-Jane Montgomery\nDOB: 12/05/1982\nAddress: 1244 North Oak Street, San Francisco, CA 94102\nEmergency Contact: Robert Montgomery (Husband) - Phone: (415) 555-0198\n\nADMISSION DIAGNOSIS:\nAcute respiratory distress. Patient was admitted to 'Green Valley General Hospital' following an incident at her workplace, 'Silicon Dynamics Corp' (Tax ID: 12-3456789).\n\nHOSPITAL COURSE:\nThe patient, Sarah-Jane Montgomery, was treated by Dr. Michael Henderson. SSN used for verification: 123-45-6789. Final billing statement sent to sj.montgomery@provider.net.`
|
| 33 |
}
|
| 34 |
];
|
| 35 |
|
|
|
|
| 38 |
const [language, setLanguage] = useState('auto');
|
| 39 |
const [result, setResult] = useState<RedactResponse | null>(null);
|
| 40 |
const [loading, setLoading] = useState(false);
|
| 41 |
+
const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'offline'>('online');
|
|
|
|
| 42 |
const [copied, setCopied] = useState(false);
|
|
|
|
| 43 |
|
| 44 |
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
| 45 |
|
|
|
|
| 59 |
const textToProcess = overrideText || text;
|
| 60 |
if (!textToProcess.trim()) return;
|
| 61 |
setLoading(true);
|
|
|
|
| 62 |
try {
|
| 63 |
const response = await axios.post(`${API_URL}/redact`, { text: textToProcess, language });
|
| 64 |
setResult(response.data);
|
| 65 |
} catch (err: any) {
|
| 66 |
+
console.error(err);
|
| 67 |
} finally {
|
| 68 |
setLoading(false);
|
| 69 |
}
|
|
|
|
| 75 |
setResult(null);
|
| 76 |
};
|
| 77 |
|
| 78 |
+
const getScoreStyles = (score: number) => {
|
| 79 |
+
if (score >= 90) return { text: 'text-rose-600', bg: 'bg-rose-500', border: 'border-rose-100', lightBg: 'bg-rose-50' };
|
| 80 |
+
if (score >= 70) return { text: 'text-amber-600', bg: 'bg-amber-500', border: 'border-amber-100', lightBg: 'bg-amber-50' };
|
| 81 |
+
return { text: 'text-emerald-600', bg: 'bg-emerald-500', border: 'border-emerald-100', lightBg: 'bg-emerald-50' };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
};
|
| 83 |
|
| 84 |
return (
|
| 85 |
+
<div className="min-h-screen bg-[#F9FAFB] text-[#111827] font-sans selection:bg-blue-100">
|
| 86 |
+
|
| 87 |
+
<div className="max-w-7xl mx-auto px-8 py-12">
|
| 88 |
+
{/* Simple & Clean Header */}
|
| 89 |
+
<header className="flex items-center justify-between mb-16">
|
| 90 |
+
<div className="flex items-center gap-3">
|
| 91 |
+
<div className="bg-slate-900 p-2 rounded-lg shadow-sm">
|
| 92 |
+
<Shield className="text-white w-5 h-5" />
|
| 93 |
+
</div>
|
|
|
|
| 94 |
<div>
|
| 95 |
+
<h1 className="text-xl font-bold tracking-tight text-slate-900">Privacy Gateway</h1>
|
| 96 |
+
<div className="flex items-center gap-2 mt-0.5">
|
| 97 |
+
<span className={`w-1.5 h-1.5 rounded-full ${apiStatus === 'online' ? 'bg-emerald-500 animate-pulse' : 'bg-rose-500'}`} />
|
| 98 |
+
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Active Security</span>
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
</div>
|
| 102 |
+
|
| 103 |
+
<div className="flex items-center gap-4">
|
| 104 |
+
<div className="flex items-center bg-white px-3 py-2 rounded-lg border border-slate-200 shadow-sm">
|
| 105 |
+
<Languages className="w-3.5 h-3.5 text-slate-400 mr-2" />
|
| 106 |
+
<select
|
| 107 |
+
value={language}
|
| 108 |
+
onChange={(e) => setLanguage(e.target.value)}
|
| 109 |
+
className="bg-transparent border-none outline-none text-[10px] font-bold uppercase text-slate-600 cursor-pointer pr-2"
|
| 110 |
+
>
|
| 111 |
+
<option value="auto">Auto-Detect</option>
|
| 112 |
<option value="en">English</option>
|
| 113 |
<option value="fr">French</option>
|
| 114 |
</select>
|
| 115 |
</div>
|
| 116 |
+
</div>
|
| 117 |
</header>
|
| 118 |
|
| 119 |
+
{/* Demo Selection */}
|
| 120 |
+
<div className="mb-12">
|
| 121 |
+
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-4">Simulations de documents</p>
|
| 122 |
+
<div className="flex gap-4">
|
|
|
|
|
|
|
|
|
|
| 123 |
{EXAMPLES.map((ex, i) => (
|
| 124 |
<button
|
| 125 |
key={i}
|
| 126 |
onClick={() => loadExample(ex.text, ex.lang)}
|
| 127 |
+
className="px-5 py-3 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 hover:border-slate-900 hover:shadow-sm transition-all active:scale-[0.98]"
|
| 128 |
>
|
| 129 |
+
{ex.label}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</button>
|
| 131 |
))}
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
|
| 135 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
| 136 |
+
{/* Input Area */}
|
| 137 |
+
<div className="lg:col-span-5">
|
| 138 |
+
<div className="bg-white rounded-2xl border border-slate-200 p-8 flex flex-col h-[650px] shadow-sm">
|
| 139 |
+
<div className="flex items-center justify-between mb-6 text-slate-400">
|
| 140 |
+
<div className="flex items-center gap-2">
|
| 141 |
+
<Database className="w-4 h-4" />
|
| 142 |
+
<span className="text-[10px] font-bold uppercase tracking-widest">Contenu Source</span>
|
| 143 |
+
</div>
|
| 144 |
+
<span className="text-[10px] font-mono">{text.length} chars</span>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
<textarea
|
| 148 |
+
className="flex-grow w-full bg-transparent text-slate-700 font-medium leading-relaxed outline-none resize-none placeholder:text-slate-300"
|
| 149 |
+
placeholder="Entrez vos données sensibles..."
|
| 150 |
value={text}
|
| 151 |
onChange={(e) => setText(e.target.value)}
|
| 152 |
/>
|
| 153 |
+
|
| 154 |
+
<button
|
| 155 |
+
onClick={() => handleRedact()}
|
| 156 |
+
disabled={loading || !text.trim()}
|
| 157 |
+
className={`mt-8 w-full py-4 rounded-xl font-bold text-sm text-white transition-all flex items-center justify-center gap-3 ${
|
| 158 |
+
loading || !text.trim()
|
| 159 |
+
? 'bg-slate-200 cursor-not-allowed text-slate-400'
|
| 160 |
+
: 'bg-slate-900 hover:bg-black active:scale-[0.99]'
|
| 161 |
+
}`}
|
| 162 |
+
>
|
| 163 |
+
{loading ? (
|
| 164 |
+
<div className="flex gap-1.5 items-center">
|
| 165 |
+
<span className="w-1 h-1 bg-white rounded-full animate-bounce [animation-delay:-0.3s]"></span>
|
| 166 |
+
<span className="w-1 h-1 bg-white rounded-full animate-bounce [animation-delay:-0.15s]"></span>
|
| 167 |
+
<span className="w-1 h-1 bg-white rounded-full animate-bounce"></span>
|
| 168 |
+
</div>
|
| 169 |
+
) : (
|
| 170 |
+
<>
|
| 171 |
+
<Zap className="w-4 h-4 fill-white" />
|
| 172 |
+
<span>Lancer l'Anonymisation</span>
|
| 173 |
+
</>
|
| 174 |
+
)}
|
| 175 |
+
</button>
|
| 176 |
</div>
|
| 177 |
</div>
|
| 178 |
|
| 179 |
+
{/* Results Area */}
|
| 180 |
+
<div className="lg:col-span-7 space-y-8">
|
| 181 |
+
<div className="bg-[#0F172A] rounded-2xl p-10 h-[450px] flex flex-col shadow-xl relative group">
|
| 182 |
+
<div className="flex items-center justify-between mb-8">
|
| 183 |
+
<div className="flex items-center gap-3">
|
| 184 |
+
<div className="w-1.5 h-1.5 bg-emerald-400 rounded-full" />
|
| 185 |
+
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-400/70">Flux de sortie sécurisé</span>
|
| 186 |
+
</div>
|
| 187 |
+
{result && (
|
| 188 |
+
<button
|
| 189 |
+
onClick={() => {navigator.clipboard.writeText(result.redacted_text); setCopied(true); setTimeout(()=>setCopied(false), 2000)}}
|
| 190 |
+
className="text-[10px] font-bold uppercase text-white/50 hover:text-white transition-colors"
|
| 191 |
+
>
|
| 192 |
+
{copied ? 'Copié' : 'Copier'}
|
| 193 |
+
</button>
|
| 194 |
+
)}
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<div className="flex-grow font-mono text-[13px] leading-relaxed text-emerald-500/90 whitespace-pre-wrap overflow-y-auto custom-scrollbar">
|
| 198 |
+
{!result ? (
|
| 199 |
+
<div className="h-full flex items-center justify-center text-slate-700 italic">
|
| 200 |
+
Aucun résultat généré
|
| 201 |
+
</div>
|
| 202 |
+
) : result.redacted_text}
|
| 203 |
+
</div>
|
| 204 |
</div>
|
| 205 |
+
|
| 206 |
+
{/* Analysis Grid with Colored Scores */}
|
| 207 |
+
{result && (
|
| 208 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 209 |
+
<div className="md:col-span-2 flex items-center gap-2 mb-2">
|
| 210 |
+
<Fingerprint className="w-4 h-4 text-slate-400" />
|
| 211 |
+
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Rapport de détection ({result.detected_language})</span>
|
| 212 |
+
</div>
|
| 213 |
+
{result.entities.map((ent, idx) => {
|
| 214 |
+
const styles = getScoreStyles(ent.score);
|
| 215 |
+
return (
|
| 216 |
+
<div key={idx} className={`p-4 rounded-xl border ${styles.border} ${styles.lightBg} flex items-center justify-between transition-all hover:shadow-sm group`}>
|
| 217 |
+
<div className="flex flex-col gap-1">
|
| 218 |
+
<span className={`text-[9px] font-black uppercase tracking-widest ${styles.text}`}>{ent.type}</span>
|
| 219 |
+
<span className="text-xs font-bold text-slate-800 line-clamp-1 italic">"{ent.text}"</span>
|
| 220 |
+
</div>
|
| 221 |
+
<div className="text-right">
|
| 222 |
+
<div className="flex items-center gap-1.5">
|
| 223 |
+
<Activity className={`w-3 h-3 ${styles.text}`} />
|
| 224 |
+
<p className={`text-[11px] font-black ${styles.text}`}>{ent.score}%</p>
|
| 225 |
+
</div>
|
| 226 |
+
<div className="w-12 h-1 bg-white/50 rounded-full mt-1.5 overflow-hidden">
|
| 227 |
+
<div className={`h-full ${styles.bg}`} style={{ width: `${ent.score}%` }} />
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
);
|
| 232 |
+
})}
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
</div>
|
| 236 |
</div>
|
| 237 |
</div>
|