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

Upload 10 files

Browse files
Files changed (4) hide show
  1. app.py +1 -1
  2. config.py +1 -1
  3. gliner_recognizer.py +100 -349
  4. pipeline.py +24 -19
app.py CHANGED
@@ -275,4 +275,4 @@ with gr.Blocks(
275
  pdf_btn.click(handle_pdf, inputs=[pdf_in, *inputs_common], outputs=outputs)
276
  demo_btn.click(lambda: DEMO_TEXT, inputs=None, outputs=txt_in)
277
 
278
- demo.launch()
 
275
  pdf_btn.click(handle_pdf, inputs=[pdf_in, *inputs_common], outputs=outputs)
276
  demo_btn.click(lambda: DEMO_TEXT, inputs=None, outputs=txt_in)
277
 
278
+ demo.launch()
config.py CHANGED
@@ -8,7 +8,7 @@ MODES: dict[str, str] = {
8
  "Solo prima lettera → J***": "first",
9
  }
10
 
11
- DEFAULT_MIN_SCORE: float = 0.85
12
 
13
  # ---------------------------------------------------------------------------
14
  # Entity type → Italian placeholder label
 
8
  "Solo prima lettera → J***": "first",
9
  }
10
 
11
+ DEFAULT_MIN_SCORE: float = 0.65
12
 
13
  # ---------------------------------------------------------------------------
14
  # Entity type → Italian placeholder label
gliner_recognizer.py CHANGED
@@ -1,366 +1,117 @@
1
- """Pipeline a 3 livelli: Regex (opz.) NER → GLiNER, chunking, agreement boost."""
2
- import hashlib
3
  import logging
4
- import re
5
- from typing import Optional
6
 
7
- from presidio_analyzer import RecognizerResult
8
-
9
- from recognizers import analyzer_full, analyzer_ner_only, POST_BOOST_PATTERNS
10
- from gliner_recognizer import GlinerRecognizer
11
- from span_resolver import resolve_overlapping_spans
12
- from config import MODES, LABEL_IT
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
- # Configurazione
17
- _POST_BOOST_DELTA: float = 0.30
18
- _POST_BOOST_MAX: float = 1.0
19
- _AGREEMENT_DELTA: float = 0.15
20
- _AGREEMENT_MAX: float = 1.0
21
- _CHUNK_SIZE: int = 1500 # ~400 token BERT
22
- _CHUNK_OVERLAP: int = 200
23
-
24
- _REGEX_RECOGNIZER_NAMES: set[str] = {
25
- "cig", "cup", "rea", "num_gara", "pec", "inps",
26
- "polizza_full", "polizza_alt",
27
- "cpv", "nuts", "ateco", "ribasso", "protocollo",
28
- "atto", "anac", "lotto", "siogg",
29
- "cf_azienda", "societa", "albo",
30
- }
31
-
32
- _PROCUREMENT_PREFIX: dict[str, str] = {
33
- "RUP": "RUP",
34
- "STAZIONE_APPALTANTE": "SA",
35
- "OPERATORE_ECONOMICO": "OE",
36
- "SUBAPPALTATORE": "SUB",
37
- "COMMISSARIO_GARA": "COMM",
38
- "DIRETTORE_LAVORI": "DL",
39
- "CODICE_CPV": "CPV",
40
- "CODICE_NUTS": "NUTS",
41
- "CODICE_ATECO": "ATECO",
42
- "NUMERO_GARA": "NUMGARA",
43
- "IMPORTO_BASE_ASTA": "IMPORTO_BASE",
44
- "PROCUREMENT_ENTITY": "PROCUREMENT",
45
- }
46
-
47
- _RECLASSIFY_PATTERNS: dict[str, re.Pattern] = {
48
- "CIG": re.compile(r"^(?:\d{7}[0-9A-F]{3}|[A-Z][0-9A-F]{9})$"),
49
- "CUP": re.compile(r"^[A-Z]\d{2}[A-Z][A-Z0-9]{2}\d{6}[A-Z0-9]{3}$"),
50
- "REA": re.compile(r"^[A-Z]{2}[\s\-/\.]\d{4,7}$"),
51
- }
52
-
53
- _GENERIC_NER_ENTITIES: set[str] = {
54
- "NUMERO_DOCUMENTO", "N_LICENZA", "N_SENTENZA", "NUMERO_CONTO",
55
  }
56
 
57
- _CURRENCY_PREFIX = re.compile(r"^(€|EUR|\$|£)\s*", flags=re.IGNORECASE)
58
-
59
- _gliner: Optional[GlinerRecognizer] = None
60
-
61
-
62
- # ---------------------------------------------------------------------------
63
- # Helpers – singleton, utils
64
- # ---------------------------------------------------------------------------
65
- def _get_gliner(threshold: float = 0.65) -> GlinerRecognizer:
66
- global _gliner
67
- if _gliner is None:
68
- _gliner = GlinerRecognizer(threshold=threshold)
69
- return _gliner
70
-
71
-
72
- def _is_regex_result(r: RecognizerResult) -> bool:
73
- if r.analysis_explanation is None:
74
- return False
75
- return r.analysis_explanation.pattern_name in _REGEX_RECOGNIZER_NAMES
76
-
77
-
78
- def _reclassify(results: list, text: str) -> list:
79
- """Rinomina entità NER generiche → CIG/CUP/REA se span matcha pattern."""
80
- for r in results:
81
- if r.entity_type in _GENERIC_NER_ENTITIES:
82
- span = text[r.start:r.end].strip()
83
- for specific, pattern in _RECLASSIFY_PATTERNS.items():
84
- if pattern.fullmatch(span):
85
- r.entity_type = specific
86
- break
87
- return results
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
95
- raw = match.group()
96
- try:
97
- value = float(raw.replace(".", "").replace(",", "."))
98
- except ValueError:
99
- return original
100
- h = int(hashlib.md5(original.encode("utf-8")).hexdigest(), 16) % 10_000
101
- factor = 1 + scale_pct * (h / 5_000 - 1)
102
- scaled = value * factor
103
- integer = int(scaled)
104
- decimals = int(round((scaled - integer) * 100))
105
- integer_str = f"{integer:,}".replace(",", ".")
106
- return original[:match.start()] + f"{integer_str},{decimals:02d}" + original[match.end():]
107
-
108
-
109
- def _apply_mode(s: str, mode: str, label: str) -> str:
110
- n = len(s)
111
- if mode == "placeholder":
112
- return f"[{label}]"
113
- if mode == "last4":
114
- keep = min(4, n)
115
- return "*" * (n - keep) + s[-keep:]
116
- if mode == "stars":
117
- return "*" * n
118
- if mode == "first":
119
- return s[0] + "*" * (n - 1) if n > 1 else s
120
- return f"[{label}]"
121
-
122
-
123
- # ---------------------------------------------------------------------------
124
- # Session – placeholder numerati coerenti per documento
125
- # ---------------------------------------------------------------------------
126
- class Session:
127
- def __init__(self):
128
- self._map: dict[tuple[str, str], str] = {}
129
- self._counters: dict[str, int] = {}
130
-
131
- def get_numbered(self, original: str, entity_type: str, prefix: str) -> str:
132
- key = (original.strip(), entity_type)
133
- if key in self._map:
134
- return self._map[key]
135
- self._counters[prefix] = self._counters.get(prefix, 0) + 1
136
- placeholder = f"[{prefix}_{self._counters[prefix]:03d}]"
137
- self._map[key] = placeholder
138
- return placeholder
139
-
140
-
141
- def _replace(original: str, entity_type: str, mode: str, priority: int, session: Session) -> str:
142
- if entity_type == "IMPORTO_BASE_ASTA" and mode == "placeholder":
143
- return _scale_amount(original)
144
- if priority == 2 and mode == "placeholder":
145
- prefix = _PROCUREMENT_PREFIX.get(entity_type, entity_type)
146
- return session.get_numbered(original, entity_type, prefix)
147
- if entity_type in ("IMPORTO_GARA", "VALUTA"):
148
- m = _CURRENCY_PREFIX.match(original)
149
- if m:
150
- sym = m.group(0)
151
- rest = original[m.end():]
152
- label = LABEL_IT.get(entity_type, entity_type)
153
- return sym + _apply_mode(rest, mode, label) if rest else sym
154
- label = LABEL_IT.get(entity_type, entity_type)
155
- return _apply_mode(original, mode, label)
156
-
157
-
158
- # ---------------------------------------------------------------------------
159
- # Ottimizzazione 1 – Chunking
160
- # ---------------------------------------------------------------------------
161
- def _chunk_text(text: str) -> list[tuple[int, str]]:
162
- if len(text) <= _CHUNK_SIZE:
163
- return [(0, text)]
164
- chunks: list[tuple[int, str]] = []
165
- start = 0
166
- step = _CHUNK_SIZE - _CHUNK_OVERLAP
167
- while start < len(text):
168
- end = min(start + _CHUNK_SIZE, len(text))
169
- chunks.append((start, text[start:end]))
170
- if end == len(text):
171
- break
172
- start += step
173
- return chunks
174
-
175
-
176
- def _dedup_by_position(results: list[RecognizerResult]) -> list[RecognizerResult]:
177
- seen: set[tuple[int, int, str]] = set()
178
- unique: list[RecognizerResult] = []
179
- for r in sorted(results, key=lambda x: -x.score):
180
- key = (r.start, r.end, r.entity_type)
181
- if key not in seen:
182
- seen.add(key)
183
- unique.append(r)
184
- return unique
185
 
 
 
 
186
 
187
- def _run_analyzer_chunked(analyzer, text: str) -> list[RecognizerResult]:
188
- chunks = _chunk_text(text)
189
- if len(chunks) == 1:
190
- return analyzer.analyze(text=text, language="it")
191
- all_results: list[RecognizerResult] = []
192
- for offset, chunk in chunks:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  try:
194
- for r in analyzer.analyze(text=chunk, language="it"):
195
- r.start += offset
196
- r.end += offset
197
- all_results.append(r)
198
  except Exception as exc:
199
- logger.error("[chunk@%d] Presidio: %s", offset, exc)
200
- return _dedup_by_position(all_results)
 
 
 
 
201
 
 
 
 
 
202
 
203
- def _run_gliner_chunked(gliner: GlinerRecognizer, text: str) -> list[RecognizerResult]:
204
- chunks = _chunk_text(text)
205
- if len(chunks) == 1:
206
- return gliner.analyze(text=text, entities=[])
207
- all_results: list[RecognizerResult] = []
208
- for offset, chunk in chunks:
209
  try:
210
- for r in gliner.analyze(text=chunk, entities=[]):
211
- r.start += offset
212
- r.end += offset
213
- all_results.append(r)
214
  except Exception as exc:
215
- logger.error("[chunk@%d] GLiNER: %s", offset, exc)
216
- return _dedup_by_position(all_results)
217
-
218
-
219
- # ---------------------------------------------------------------------------
220
- # Ottimizzazione 2 – Cross-layer agreement boost
221
- # ---------------------------------------------------------------------------
222
- def _cross_layer_boost(layers: list[list[RecognizerResult]]) -> list[list[RecognizerResult]]:
223
- """Score +0.15 se 2+ livelli rilevano lo stesso span (Jaccard ≥ 80%)."""
224
- flat = [(i, r) for i, layer in enumerate(layers) for r in layer]
225
-
226
- def _jaccard(a: RecognizerResult, b: RecognizerResult) -> float:
227
- inter = max(0, min(a.end, b.end) - max(a.start, b.start))
228
- union = max(a.end, b.end) - min(a.start, b.start)
229
- return inter / union if union > 0 else 0.0
230
-
231
- for i, r in flat:
232
- agreeing: set[int] = {i}
233
- for j, other in flat:
234
- if i != j and _jaccard(r, other) >= 0.8:
235
- agreeing.add(j)
236
- if len(agreeing) >= 2:
237
- r.score = min(_AGREEMENT_MAX, r.score + _AGREEMENT_DELTA)
238
- if r.recognition_metadata is None:
239
- r.recognition_metadata = {}
240
- r.recognition_metadata["cross_layer_agreement"] = len(agreeing)
241
- return layers
242
-
243
-
244
- # ---------------------------------------------------------------------------
245
- # Post-boost regex
246
- # ---------------------------------------------------------------------------
247
- def _post_boost_check(entities: list, text: str) -> list:
248
- """Score +0.30 e flag post_boost=True se span matcha POST_BOOST_PATTERNS."""
249
- for r in entities:
250
- pattern = POST_BOOST_PATTERNS.get(r.entity_type)
251
- if pattern and pattern.fullmatch(text[r.start:r.end].strip()):
252
- r.score = min(_POST_BOOST_MAX, r.score + _POST_BOOST_DELTA)
253
- if r.recognition_metadata is None:
254
- r.recognition_metadata = {}
255
- r.recognition_metadata["post_boost"] = True
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
-
306
- analyzer = analyzer_full if use_regex else analyzer_ner_only
307
- try:
308
- presidio_results = _run_analyzer_chunked(analyzer, text)
309
- except Exception as exc:
310
- logger.error("[pipeline] Presidio: %s", exc)
311
- presidio_results = []
312
-
313
- if use_regex:
314
- regex_results = [r for r in presidio_results if _is_regex_result(r)]
315
- ner_results = [r for r in presidio_results if not _is_regex_result(r)]
316
- else:
317
- regex_results = []
318
- ner_results = presidio_results
319
-
320
- ner_results = _reclassify(ner_results, text)
321
-
322
- try:
323
- gliner_results = _run_gliner_chunked(_get_gliner(threshold=min_score), text)
324
- except Exception as exc:
325
- logger.error("[pipeline] GLiNER: %s", exc)
326
- gliner_results = []
327
-
328
- regex_results = [r for r in regex_results if r.score >= min_score]
329
- ner_results = [r for r in ner_results if r.score >= min_score]
330
- gliner_results = [r for r in gliner_results if r.score >= min_score]
331
-
332
- regex_results, ner_results, gliner_results = _cross_layer_boost(
333
- [regex_results, ner_results, gliner_results]
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:
343
- """Sostituisce le entità nel testo secondo la modalità scelta."""
344
- if not entities:
345
- return text
346
- mode = MODES.get(mode_label, "placeholder")
347
- session = Session()
348
- for r in sorted(entities, key=lambda r: r.start, reverse=True):
349
- original = text[r.start:r.end]
350
- priority = (r.recognition_metadata or {}).get("source_priority", -1)
351
- replacement = _replace(original, r.entity_type, mode, priority, session)
352
- text = text[:r.start] + replacement + text[r.end:]
353
- return text
354
-
355
-
356
- def anonymize(
357
- text: str,
358
- mode_label: str,
359
- min_score: float = 0.65,
360
- use_regex: bool = True,
361
- ) -> tuple[str, list[RecognizerResult]]:
362
- """Pipeline completa: detect + anonimizza. Returns (testo_offuscato, entità)."""
363
- if not text or not text.strip():
364
- return "", []
365
- entities = detect(text, min_score=min_score, use_regex=use_regex)
366
- return anonymize_from_entities(text, entities, mode_label), entities
 
1
+ """GLiNER recognizer Presidio-compatible Livello 3 della pipeline."""
 
2
  import logging
3
+ from typing import List, Optional
 
4
 
5
+ from presidio_analyzer import EntityRecognizer, RecognizerResult
 
 
 
 
 
6
 
7
  logger = logging.getLogger(__name__)
8
 
9
+ PROCUREMENT_LABELS: list[str] = [
10
+ "CIG",
11
+ "CUP",
12
+ "RUP",
13
+ "stazione appaltante",
14
+ "responsabile del procedimento",
15
+ "operatore economico",
16
+ "subappaltatore",
17
+ "importo a base d'asta",
18
+ "codice CPV",
19
+ "codice NUTS",
20
+ "codice ATECO",
21
+ "numero gara",
22
+ "commissario di gara",
23
+ "direttore dei lavori",
24
+ ]
25
+
26
+ _LABEL_TO_ENTITY: dict[str, str] = {
27
+ "CIG": "CIG",
28
+ "CUP": "CUP",
29
+ "RUP": "RUP",
30
+ "stazione appaltante": "STAZIONE_APPALTANTE",
31
+ "responsabile del procedimento": "RUP",
32
+ "operatore economico": "OPERATORE_ECONOMICO",
33
+ "subappaltatore": "SUBAPPALTATORE",
34
+ "importo a base d'asta": "IMPORTO_BASE_ASTA",
35
+ "codice CPV": "CODICE_CPV",
36
+ "codice NUTS": "CODICE_NUTS",
37
+ "codice ATECO": "CODICE_ATECO",
38
+ "numero gara": "NUMERO_GARA",
39
+ "commissario di gara": "COMMISSARIO_GARA",
40
+ "direttore dei lavori": "DIRETTORE_LAVORI",
 
 
 
 
 
 
 
41
  }
42
 
43
+ 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,
56
+ threshold: float = 0.65,
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:
79
+ from gliner import GLiNER
80
+ self._model = GLiNER.from_pretrained(self.model_name)
81
+ logger.info("[GLiNER] Modello %s caricato", self.model_name)
 
82
  except Exception as exc:
83
+ logger.error("[GLiNER] Caricamento fallito: %s", exc)
84
+ self._model = False
85
+
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 []
102
+
103
+ results: list[RecognizerResult] = []
104
+ for p in predictions:
105
+ entity_type = _LABEL_TO_ENTITY.get(p["label"], "PROCUREMENT_ENTITY")
106
+ results.append(RecognizerResult(
107
+ entity_type=entity_type,
108
+ start=p["start"],
109
+ end=p["end"],
110
+ score=float(p.get("score", self.threshold)),
111
+ analysis_explanation=None,
112
+ recognition_metadata={
113
+ "recognizer_name": "GlinerRecognizer",
114
+ "gliner_label": p["label"],
115
+ },
116
+ ))
117
+ return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pipeline.py CHANGED
@@ -1,4 +1,4 @@
1
- """Pipeline a 3 livelli: Regex (opz.) → NER → GLiNER, con chunking e agreement boost."""
2
  import hashlib
3
  import logging
4
  import re
@@ -60,7 +60,7 @@ _gliner: Optional[GlinerRecognizer] = None
60
 
61
 
62
  # ---------------------------------------------------------------------------
63
- # Helpers
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 numerico ±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
125
  # ---------------------------------------------------------------------------
126
  class Session:
127
  def __init__(self):
@@ -244,6 +244,21 @@ def _cross_layer_boost(layers: list[list[RecognizerResult]]) -> list[list[Recogn
244
  # ---------------------------------------------------------------------------
245
  # Post-boost regex
246
  # ---------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  def _filter_noise(entities: list, text: str) -> list:
248
  """
249
  Rimuove falsi positivi prodotti da re-parsing di testo già offuscato.
@@ -251,9 +266,8 @@ def _filter_noise(entities: list, text: str) -> list:
251
  Casi gestiti:
252
  • Span con >50% asterischi → già mascherato, salta
253
  • VALUTA/IMPORTO senza cifre → simbolo isolato (€, EUR), salta
254
- • FREQUENZA con span di 1 parola e score < 0.80 → troppo generico, salta
255
- Entità che iniziano o finiscono con una parentesi aperta/chiusa
256
- (artefatto di boundary NER) → salta
257
  """
258
  clean: list = []
259
  for r in entities:
@@ -268,33 +282,24 @@ def _filter_noise(entities: list, text: str) -> list:
268
  if not re.search(r"\d", span):
269
  continue
270
 
271
- # FREQUENZA con singola parola senza contesto → falso positivo comune
272
  if r.entity_type == "FREQUENZA" and r.score < 0.80:
273
  continue
274
 
275
- # Boundary NER rotto: span inizia o finisce con una parentesi
276
  stripped = span.strip()
277
  if stripped.startswith("(") or stripped.endswith("("):
278
  continue
279
 
280
  clean.append(r)
281
  return clean
282
- """Score +0.30 e flag post_boost=True se span matcha POST_BOOST_PATTERNS."""
283
- for r in entities:
284
- pattern = POST_BOOST_PATTERNS.get(r.entity_type)
285
- if pattern and pattern.fullmatch(text[r.start:r.end].strip()):
286
- r.score = min(_POST_BOOST_MAX, r.score + _POST_BOOST_DELTA)
287
- if r.recognition_metadata is None:
288
- r.recognition_metadata = {}
289
- r.recognition_metadata["post_boost"] = True
290
- return entities
291
 
292
 
293
  # ---------------------------------------------------------------------------
294
  # Pipeline pubblica
295
  # ---------------------------------------------------------------------------
296
  def detect(text: str, min_score: float = 0.65, use_regex: bool = True) -> list[RecognizerResult]:
297
- """Rilevazione a 3 livelli con chunking + cross-layer boost + post-boost."""
298
  if not text or not text.strip():
299
  return []
300
 
 
1
+ """Pipeline a 3 livelli: Regex (opz.) → NER → GLiNER, chunking, agreement boost."""
2
  import hashlib
3
  import logging
4
  import re
 
60
 
61
 
62
  # ---------------------------------------------------------------------------
63
+ # Helpers – singleton, utils
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 ±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 per documento
125
  # ---------------------------------------------------------------------------
126
  class Session:
127
  def __init__(self):
 
244
  # ---------------------------------------------------------------------------
245
  # Post-boost regex
246
  # ---------------------------------------------------------------------------
247
+ def _post_boost_check(entities: list, text: str) -> list:
248
+ """Score +0.30 e flag post_boost=True se span matcha POST_BOOST_PATTERNS."""
249
+ for r in entities:
250
+ pattern = POST_BOOST_PATTERNS.get(r.entity_type)
251
+ if pattern and pattern.fullmatch(text[r.start:r.end].strip()):
252
+ r.score = min(_POST_BOOST_MAX, r.score + _POST_BOOST_DELTA)
253
+ if r.recognition_metadata is None:
254
+ r.recognition_metadata = {}
255
+ r.recognition_metadata["post_boost"] = True
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.
 
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:
 
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