TomassiniDigital commited on
Commit
a499b83
·
verified ·
1 Parent(s): 8620c12

Upload 10 files

Browse files
Files changed (3) hide show
  1. app.py +8 -138
  2. gliner_recognizer.py +7 -21
  3. pipeline.py +6 -47
app.py CHANGED
@@ -11,33 +11,16 @@ from demo_text import DEMO_TEXT
11
  # ---------------------------------------------------------------------------
12
  # Handlers
13
  # ---------------------------------------------------------------------------
14
- import re as _re
15
-
16
- _ALREADY_ANON_RE = _re.compile(r"\[[A-Z_]+(?:_\d+)?\]|(\*{3,})")
17
-
18
-
19
  def _process(text: str, mode: str, min_score: float, use_regex: bool):
20
  if not text or not text.strip():
21
  e = '<div style="padding:40px; text-align:center; color:#6b7280; font-family:Arial,sans-serif;">Inserisci del testo per iniziare.</div>'
22
  return e, e, e
23
-
24
- # Avviso se il testo sembra già offuscato
25
- warning = ""
26
- if _ALREADY_ANON_RE.search(text):
27
- warning = (
28
- '<div style="background:#fefce8; border-left:4px solid #ca8a04; '
29
- 'padding:10px 16px; margin-bottom:12px; border-radius:4px; '
30
- 'font-family:Arial,sans-serif; font-size:0.9em; color:#713f12;">'
31
- '⚠️ Il testo sembra già parzialmente offuscato (contiene placeholder o asterischi). '
32
- 'I risultati potrebbero includere falsi positivi sulle parti già mascherate.'
33
- '</div>'
34
- )
35
-
36
  anon_text, entities = anonymize(text, mode, min_score, use_regex)
37
- highlighted = warning + render_highlighted_text(text, entities)
38
- anonymized = render_anonymized_text(anon_text)
39
- report = warning + render_categorized_report(text, entities)
40
- return highlighted, anonymized, report
 
41
 
42
 
43
  def handle_text(text, mode, min_score, use_regex):
@@ -92,118 +75,6 @@ _CSS = """
92
  color: #6b7280 !important; margin-bottom: 6px !important;
93
  }
94
  button.primary { font-weight: 600 !important; }
95
-
96
- /* ── Loading overlay ─────────────────────────────────────────────────── */
97
- #anon-overlay {
98
- display: none;
99
- position: fixed;
100
- inset: 0;
101
- background: rgba(15, 23, 42, 0.72);
102
- backdrop-filter: blur(4px);
103
- -webkit-backdrop-filter: blur(4px);
104
- z-index: 10000;
105
- justify-content: center;
106
- align-items: center;
107
- }
108
- #anon-overlay.anon-visible { display: flex; }
109
- #anon-box {
110
- background: #ffffff;
111
- border-radius: 16px;
112
- padding: 48px 64px;
113
- text-align: center;
114
- box-shadow: 0 24px 64px rgba(0,0,0,0.35);
115
- min-width: 300px;
116
- }
117
- #anon-ring {
118
- width: 60px; height: 60px;
119
- border: 5px solid #dbeafe;
120
- border-top-color: #1d4ed8;
121
- border-radius: 50%;
122
- animation: anon-spin 0.85s linear infinite;
123
- margin: 0 auto 24px;
124
- }
125
- #anon-title {
126
- font-size: 1.15em; font-weight: 700;
127
- color: #1e3a8a; margin: 0 0 8px;
128
- }
129
- #anon-sub {
130
- font-size: 0.82em; color: #6b7280; margin: 0;
131
- }
132
- @keyframes anon-spin { to { transform: rotate(360deg); } }
133
- """
134
-
135
- # ---------------------------------------------------------------------------
136
- # JS – overlay a schermo intero durante l'analisi
137
- # ---------------------------------------------------------------------------
138
- _JS = """
139
- () => {
140
- /* ── Crea overlay ──────────────────────────────────────────────── */
141
- const ov = document.createElement('div');
142
- ov.id = 'anon-overlay';
143
- ov.innerHTML = `
144
- <div id="anon-box">
145
- <div id="anon-ring"></div>
146
- <p id="anon-title">Analisi in corso&hellip;</p>
147
- <p id="anon-sub">Regex &rarr; NER &rarr; GLiNER</p>
148
- </div>`;
149
- document.body.appendChild(ov);
150
-
151
- let processing = false;
152
- let hideTimer = null;
153
-
154
- const show = () => {
155
- processing = true;
156
- clearTimeout(hideTimer);
157
- ov.classList.add('anon-visible');
158
- /* Sicurezza: auto-hide dopo 3 minuti */
159
- hideTimer = setTimeout(hide, 180_000);
160
- };
161
-
162
- const hide = () => {
163
- processing = false;
164
- clearTimeout(hideTimer);
165
- ov.classList.remove('anon-visible');
166
- };
167
-
168
- /* ── Osserva gli output HTML specifici per nascondere overlay ──── */
169
- /* Quando Gradio aggiorna un gr.HTML(), cambia il suo innerHTML. */
170
- /* Usiamo elem_id per trovare i 3 output in modo affidabile. */
171
- const OUTPUT_IDS = ['out-highlighted', 'out-anonymized', 'out-report'];
172
- const outputObs = new MutationObserver(() => {
173
- if (processing) {
174
- clearTimeout(hideTimer);
175
- /* Piccolo delay per consentire a Gradio di finire il render */
176
- hideTimer = setTimeout(hide, 200);
177
- }
178
- });
179
-
180
- function attachOutputObservers() {
181
- OUTPUT_IDS.forEach(id => {
182
- const el = document.getElementById(id);
183
- if (el && !el.dataset.anonObs) {
184
- el.dataset.anonObs = '1';
185
- outputObs.observe(el, { childList: true, subtree: true, characterData: true });
186
- }
187
- });
188
- }
189
-
190
- /* ── Collega click ai bottoni "Anonimizza" ─────────────────────── */
191
- function bindButtons() {
192
- document.querySelectorAll('button').forEach(btn => {
193
- if (btn.textContent.trim() === 'Anonimizza' && !btn.dataset.anonBound) {
194
- btn.dataset.anonBound = '1';
195
- btn.addEventListener('click', show);
196
- }
197
- });
198
- attachOutputObservers();
199
- }
200
-
201
- /* MutationObserver globale solo per scoprire nuovi elementi Gradio */
202
- const initObs = new MutationObserver(bindButtons);
203
- initObs.observe(document.body, { childList: true, subtree: true });
204
-
205
- [300, 1000, 2500].forEach(t => setTimeout(bindButtons, t));
206
- }
207
  """
208
 
209
  # ---------------------------------------------------------------------------
@@ -214,7 +85,6 @@ with gr.Blocks(
214
  theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate",
215
  font=["Arial", "Helvetica", "sans-serif"]),
216
  css=_CSS,
217
- js=_JS,
218
  ) as demo:
219
 
220
  with gr.Row(elem_classes=["header-block"]):
@@ -262,11 +132,11 @@ with gr.Blocks(
262
  gr.Markdown("### Risultato", elem_classes=["section-label"])
263
  with gr.Tabs():
264
  with gr.TabItem("🎨 Evidenziato"):
265
- out_highlighted = gr.HTML(elem_id="out-highlighted")
266
  with gr.TabItem("🔒 Anonimizzato"):
267
- out_anonymized = gr.HTML(elem_id="out-anonymized")
268
  with gr.TabItem("📊 Report"):
269
- out_report = gr.HTML(elem_id="out-report")
270
 
271
  outputs = [out_highlighted, out_anonymized, out_report]
272
  inputs_common = [mode_radio, score_slider, use_regex_chk]
 
11
  # ---------------------------------------------------------------------------
12
  # Handlers
13
  # ---------------------------------------------------------------------------
 
 
 
 
 
14
  def _process(text: str, mode: str, min_score: float, use_regex: bool):
15
  if not text or not text.strip():
16
  e = '<div style="padding:40px; text-align:center; color:#6b7280; font-family:Arial,sans-serif;">Inserisci del testo per iniziare.</div>'
17
  return e, e, e
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  anon_text, entities = anonymize(text, mode, min_score, use_regex)
19
+ return (
20
+ render_highlighted_text(text, entities),
21
+ render_anonymized_text(anon_text),
22
+ render_categorized_report(text, entities),
23
+ )
24
 
25
 
26
  def handle_text(text, mode, min_score, use_regex):
 
75
  color: #6b7280 !important; margin-bottom: 6px !important;
76
  }
77
  button.primary { font-weight: 600 !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  """
79
 
80
  # ---------------------------------------------------------------------------
 
85
  theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate",
86
  font=["Arial", "Helvetica", "sans-serif"]),
87
  css=_CSS,
 
88
  ) as demo:
89
 
90
  with gr.Row(elem_classes=["header-block"]):
 
132
  gr.Markdown("### Risultato", elem_classes=["section-label"])
133
  with gr.Tabs():
134
  with gr.TabItem("🎨 Evidenziato"):
135
+ out_highlighted = gr.HTML()
136
  with gr.TabItem("🔒 Anonimizzato"):
137
+ out_anonymized = gr.HTML()
138
  with gr.TabItem("📊 Report"):
139
+ out_report = gr.HTML()
140
 
141
  outputs = [out_highlighted, out_anonymized, out_report]
142
  inputs_common = [mode_radio, score_slider, use_regex_chk]
gliner_recognizer.py CHANGED
@@ -44,12 +44,7 @@ SUPPORTED_ENTITIES: list[str] = sorted(set(_LABEL_TO_ENTITY.values()))
44
 
45
 
46
  class GlinerRecognizer(EntityRecognizer):
47
- """
48
- Wrapper Presidio per GLiNER zero-shot.
49
-
50
- IMPORTANTE: self._model viene inizializzato PRIMA di super().__init__()
51
- perché Presidio chiama self.load() durante l'inizializzazione del parent.
52
- """
53
 
54
  def __init__(
55
  self,
@@ -57,22 +52,17 @@ class GlinerRecognizer(EntityRecognizer):
57
  labels: Optional[list[str]] = None,
58
  model_name: str = "DeepMount00/GLiNER_PII_ITA",
59
  ):
60
- # ── DEVE essere prima di super().__init__() ────────────────────────
61
- self._model = None # None = non caricato, False = caricamento fallito
62
- self.threshold = threshold
63
- self.labels = labels or PROCUREMENT_LABELS
64
- self.model_name = model_name
65
- # ──────────────────────────────────────────────────────────────────
66
  super().__init__(
67
  supported_entities=SUPPORTED_ENTITIES,
68
  supported_language="it",
69
  name="GlinerRecognizer",
70
  )
 
 
 
 
71
 
72
  def load(self) -> None:
73
- """Carica il modello GLiNER (idempotente)."""
74
- if not hasattr(self, "_model"):
75
- self._model = None
76
  if self._model is not None:
77
  return
78
  try:
@@ -86,16 +76,12 @@ class GlinerRecognizer(EntityRecognizer):
86
  def analyze(self, text: str, entities: List[str], nlp_artifacts=None) -> List[RecognizerResult]:
87
  if not text or not text.strip():
88
  return []
89
-
90
- if not hasattr(self, "_model") or self._model is None:
91
  self.load()
92
  if self._model is False:
93
  return []
94
-
95
  try:
96
- predictions = self._model.predict_entities(
97
- text, self.labels, threshold=self.threshold,
98
- )
99
  except Exception as exc:
100
  logger.error("[GLiNER] Predizione fallita: %s", exc)
101
  return []
 
44
 
45
 
46
  class GlinerRecognizer(EntityRecognizer):
47
+ """Wrapper Presidio per GLiNER zero-shot. Lazy loading, gestione errori robusta."""
 
 
 
 
 
48
 
49
  def __init__(
50
  self,
 
52
  labels: Optional[list[str]] = None,
53
  model_name: str = "DeepMount00/GLiNER_PII_ITA",
54
  ):
 
 
 
 
 
 
55
  super().__init__(
56
  supported_entities=SUPPORTED_ENTITIES,
57
  supported_language="it",
58
  name="GlinerRecognizer",
59
  )
60
+ self.threshold = threshold
61
+ self.labels = labels or PROCUREMENT_LABELS
62
+ self.model_name = model_name
63
+ self._model = None # None = non caricato, False = fallito
64
 
65
  def load(self) -> None:
 
 
 
66
  if self._model is not None:
67
  return
68
  try:
 
76
  def analyze(self, text: str, entities: List[str], nlp_artifacts=None) -> List[RecognizerResult]:
77
  if not text or not text.strip():
78
  return []
79
+ if self._model is None:
 
80
  self.load()
81
  if self._model is False:
82
  return []
 
83
  try:
84
+ predictions = self._model.predict_entities(text, self.labels, threshold=self.threshold)
 
 
85
  except Exception as exc:
86
  logger.error("[GLiNER] Predizione fallita: %s", exc)
87
  return []
pipeline.py CHANGED
@@ -1,4 +1,4 @@
1
- """Pipeline a 3 livelli: Regex (opz.) → NER → GLiNER, chunking, agreement boost."""
2
  import hashlib
3
  import logging
4
  import re
@@ -60,7 +60,7 @@ _gliner: Optional[GlinerRecognizer] = None
60
 
61
 
62
  # ---------------------------------------------------------------------------
63
- # Helpers – singleton, utils
64
  # ---------------------------------------------------------------------------
65
  def _get_gliner(threshold: float = 0.65) -> GlinerRecognizer:
66
  global _gliner
@@ -88,7 +88,7 @@ def _reclassify(results: list, text: str) -> list:
88
 
89
 
90
  def _scale_amount(original: str, scale_pct: float = 0.20) -> str:
91
- """Scala importo ±scale_pct deterministicamente (hash MD5)."""
92
  match = re.search(r"[\d\.,]+", original)
93
  if not match:
94
  return original
@@ -121,7 +121,7 @@ def _apply_mode(s: str, mode: str, label: str) -> str:
121
 
122
 
123
  # ---------------------------------------------------------------------------
124
- # Session – placeholder numerati coerenti per documento
125
  # ---------------------------------------------------------------------------
126
  class Session:
127
  def __init__(self):
@@ -256,50 +256,11 @@ def _post_boost_check(entities: list, text: str) -> list:
256
  return entities
257
 
258
 
259
- # ---------------------------------------------------------------------------
260
- # Filtro falsi positivi da re-parsing
261
- # ---------------------------------------------------------------------------
262
- def _filter_noise(entities: list, text: str) -> list:
263
- """
264
- Rimuove falsi positivi prodotti da re-parsing di testo già offuscato.
265
-
266
- Casi gestiti:
267
- • Span con >50% asterischi → già mascherato, salta
268
- • VALUTA/IMPORTO senza cifre → simbolo isolato (€, EUR), salta
269
- • FREQUENZA con score < 0.80 → falso positivo comune, salta
270
- • Span che inizia/finisce con parentesi aperta → boundary NER rotto, salta
271
- """
272
- clean: list = []
273
- for r in entities:
274
- span = text[r.start:r.end]
275
-
276
- # Span quasi interamente asterischi (testo già mascherato)
277
- if len(span) > 1 and span.count("*") / len(span) > 0.5:
278
- continue
279
-
280
- # VALUTA/IMPORTO senza nessuna cifra → simbolo isolato
281
- if r.entity_type in ("VALUTA", "IMPORTO_GARA"):
282
- if not re.search(r"\d", span):
283
- continue
284
-
285
- # FREQUENZA generica senza context sufficiente
286
- if r.entity_type == "FREQUENZA" and r.score < 0.80:
287
- continue
288
-
289
- # Boundary NER rotto: span inizia o finisce con parentesi aperta
290
- stripped = span.strip()
291
- if stripped.startswith("(") or stripped.endswith("("):
292
- continue
293
-
294
- clean.append(r)
295
- return clean
296
-
297
-
298
  # ---------------------------------------------------------------------------
299
  # Pipeline pubblica
300
  # ---------------------------------------------------------------------------
301
  def detect(text: str, min_score: float = 0.65, use_regex: bool = True) -> list[RecognizerResult]:
302
- """Rilevazione a 3 livelli con chunking + cross-layer boost + post-boost + noise filter."""
303
  if not text or not text.strip():
304
  return []
305
 
@@ -334,9 +295,7 @@ def detect(text: str, min_score: float = 0.65, use_regex: bool = True) -> list[R
334
  )
335
 
336
  final = resolve_overlapping_spans([regex_results, ner_results, gliner_results])
337
- final = _post_boost_check(final, text)
338
- final = _filter_noise(final, text)
339
- return final
340
 
341
 
342
  def anonymize_from_entities(text: str, entities: list[RecognizerResult], mode_label: str) -> str:
 
1
+ """Pipeline a 3 livelli: Regex (opz.) → NER → GLiNER, con chunking e agreement boost."""
2
  import hashlib
3
  import logging
4
  import re
 
60
 
61
 
62
  # ---------------------------------------------------------------------------
63
+ # Helpers
64
  # ---------------------------------------------------------------------------
65
  def _get_gliner(threshold: float = 0.65) -> GlinerRecognizer:
66
  global _gliner
 
88
 
89
 
90
  def _scale_amount(original: str, scale_pct: float = 0.20) -> str:
91
+ """Scala importo numerico ±scale_pct deterministicamente (hash MD5)."""
92
  match = re.search(r"[\d\.,]+", original)
93
  if not match:
94
  return original
 
121
 
122
 
123
  # ---------------------------------------------------------------------------
124
+ # Session – placeholder numerati coerenti
125
  # ---------------------------------------------------------------------------
126
  class Session:
127
  def __init__(self):
 
256
  return entities
257
 
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  # ---------------------------------------------------------------------------
260
  # Pipeline pubblica
261
  # ---------------------------------------------------------------------------
262
  def detect(text: str, min_score: float = 0.65, use_regex: bool = True) -> list[RecognizerResult]:
263
+ """Rilevazione a 3 livelli con chunking + cross-layer boost + post-boost."""
264
  if not text or not text.strip():
265
  return []
266
 
 
295
  )
296
 
297
  final = resolve_overlapping_spans([regex_results, ner_results, gliner_results])
298
+ return _post_boost_check(final, text)
 
 
299
 
300
 
301
  def anonymize_from_entities(text: str, entities: list[RecognizerResult], mode_label: str) -> str: