colscan 0.2.0__py3-none-any.whl
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.
- colscan/__init__.py +20 -0
- colscan/__main__.py +10 -0
- colscan/_engine/PROVENANCE.json +20 -0
- colscan/_engine/__init__.py +7 -0
- colscan/_engine/colname.py +276 -0
- colscan/_engine/taxonomy.py +157 -0
- colscan/_engine/validators.py +867 -0
- colscan/_engine/validators_it.py +578 -0
- colscan/cli.py +73 -0
- colscan/readers.py +296 -0
- colscan/reference.py +58 -0
- colscan/reference_data/SOURCES.md +66 -0
- colscan/reference_data/__init__.py +10 -0
- colscan/reference_data/icd10.txt +74721 -0
- colscan/reference_data/it_ateco.txt +4229 -0
- colscan/reference_data/it_ipa.json +146525 -0
- colscan/reference_data/it_territori.json +16187 -0
- colscan/report.py +149 -0
- colscan/scan.py +454 -0
- colscan-0.2.0.dist-info/METADATA +278 -0
- colscan-0.2.0.dist-info/RECORD +25 -0
- colscan-0.2.0.dist-info/WHEEL +5 -0
- colscan-0.2.0.dist-info/entry_points.txt +2 -0
- colscan-0.2.0.dist-info/licenses/LICENSE +202 -0
- colscan-0.2.0.dist-info/top_level.txt +1 -0
colscan/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""colscan — tell what is inside a table's columns, offline, with rules only.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from colscan import scan_column, scan_table
|
|
6
|
+
|
|
7
|
+
`scan_column(values, name=None)` classifies one column; `scan_table(source)`
|
|
8
|
+
opens a CSV, a Parquet file or a database table and classifies every column.
|
|
9
|
+
|
|
10
|
+
Everything here is deterministic: a value is an IBAN because it passes the mod-97
|
|
11
|
+
check, not because a model thinks so. Nothing is sent anywhere.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
|
|
18
|
+
from .scan import scan_column, scan_table # noqa: E402,F401
|
|
19
|
+
|
|
20
|
+
__all__ = ["scan_column", "scan_table", "__version__"]
|
colscan/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""`python -m colscan …`, for whoever cannot put a console script on PATH.
|
|
2
|
+
|
|
3
|
+
Same entry point as the `colscan` command: a locked-down machine where `pip
|
|
4
|
+
install --user` puts the script somewhere that is not in PATH is exactly the kind
|
|
5
|
+
of machine this tool is for.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .cli import main
|
|
9
|
+
|
|
10
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": {
|
|
3
|
+
"colname.py": {
|
|
4
|
+
"commit": "11651c91b579ae7c3d00eefb71e78f1af7a5b7e8",
|
|
5
|
+
"sha256": "b4c7ce39025926eed19edcdb7ac0593993916752b000d724e74cf011128d881b"
|
|
6
|
+
},
|
|
7
|
+
"taxonomy.py": {
|
|
8
|
+
"commit": "f3fe522b09244d013d59de7ebd5a6af38a143de1",
|
|
9
|
+
"sha256": "b2c2c387e97edbb4d471c702da25c8ac9072bb9856f7eff527c6ad344e0e8938"
|
|
10
|
+
},
|
|
11
|
+
"validators.py": {
|
|
12
|
+
"commit": "c42f863d42b4f1d20ffa3230e65e3e7f6d1e1937",
|
|
13
|
+
"sha256": "c4559e6b726e223756680505b8db2513d2c9b8db35448ce45a7dac05e4027b07"
|
|
14
|
+
},
|
|
15
|
+
"validators_it.py": {
|
|
16
|
+
"commit": "11651c91b579ae7c3d00eefb71e78f1af7a5b7e8",
|
|
17
|
+
"sha256": "9a13f858896a838178a7bf2adf4218b51361bafb788ba83a92eed6da70345b98"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Vendored deterministic engine — generated by tools/sync_engine.py.
|
|
2
|
+
|
|
3
|
+
Do not edit these files here: edit backend/colscan/ in the dbtool-platform repo
|
|
4
|
+
and re-run the sync script. `PROVENANCE.json` records the source commit and the
|
|
5
|
+
sha256 of every file, and `tools/sync_engine.py` (run by the test suite) fails if
|
|
6
|
+
the copy drifts from the backend.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# ---------------------------------------------------------------------------
|
|
2
|
+
# VENDORED FILE — DO NOT EDIT HERE.
|
|
3
|
+
# Source of truth: backend/colscan/colname.py in the private repo
|
|
4
|
+
# sheppard94g/dbtool-platform, commit 11651c91b579ae7c3d00eefb71e78f1af7a5b7e8.
|
|
5
|
+
# Regenerate with: python tools/sync_engine.py --write
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
"""Canale sul NOME della colonna.
|
|
8
|
+
|
|
9
|
+
Perché esiste, con la prova. Alla prima misura su 190 colonne di open data veri
|
|
10
|
+
il sistema a sole celle sbagliava così:
|
|
11
|
+
|
|
12
|
+
measurement → sequential_id 18 colonne
|
|
13
|
+
enum_code → sequential_id 22 colonne
|
|
14
|
+
|
|
15
|
+
Non è un difetto del modello: è che l'informazione **non è nella cella**. Nelle
|
|
16
|
+
tabelle vere l'unità di misura sta nell'intestazione (`superficie_mq`,
|
|
17
|
+
`consumo_kwh`, `importo_euro`) e la cella contiene `1234`. Nessun classificatore
|
|
18
|
+
di celle, per quanto addestrato, può decidere se `1234` è una superficie, un
|
|
19
|
+
codice interno o una chiave: l'informazione è stata separata dal dato a monte,
|
|
20
|
+
da chi ha progettato la tabella.
|
|
21
|
+
|
|
22
|
+
Come è costruito, e perché non è una rete. Un lessico esplicito è **verificabile
|
|
23
|
+
da chi firma il registro dei trattamenti**: se lo strumento dice "questa colonna
|
|
24
|
+
contiene codici fiscali", il DPO può leggere la regola che l'ha detto. In uno
|
|
25
|
+
strumento di conformità questo vale più di qualche punto di accuratezza.
|
|
26
|
+
|
|
27
|
+
La cautela che rende il tutto onesto: **il nome non può inventare un tipo
|
|
28
|
+
incompatibile con i valori osservati.** Se l'intestazione dice `nome` ma la
|
|
29
|
+
colonna contiene IBAN, vincono gli IBAN. Il nome può promuovere un tipo solo se
|
|
30
|
+
la FORMA delle celle è compatibile — è la contromisura al rischio, dichiarato
|
|
31
|
+
nello spec, che il sistema impari a leggere solo l'etichetta e resti cieco sui
|
|
32
|
+
dati (che è poi il caso `f_12`, quello per cui il prodotto esiste).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
import unicodedata
|
|
39
|
+
|
|
40
|
+
# Forma grossolana delle celle: serve solo a dire se una promozione da nome è
|
|
41
|
+
# plausibile. Volutamente a maglie larghe.
|
|
42
|
+
FORME = {
|
|
43
|
+
"numerico": {"sequential_id", "amount", "measurement", "year", "coordinate",
|
|
44
|
+
"postcode", "phone", "national_id", "boolean", "gender_label"},
|
|
45
|
+
"codice": {"enum_code", "fiscal_code_it", "vat_number", "iban", "bic",
|
|
46
|
+
"credit_card", "uuid", "hash_hex", "icd_code", "atc_code",
|
|
47
|
+
"country_code", "currency_code", "national_id", "postcode",
|
|
48
|
+
"mac_address", "boolean", "gender_label", "sequential_id"},
|
|
49
|
+
"testo": {"person_name_full", "person_given", "person_surname", "city",
|
|
50
|
+
"company_name", "institution_name", "street_address", "free_text",
|
|
51
|
+
"country_name", "region_admin", "job_title", "legal_form",
|
|
52
|
+
"gender_label", "enum_code"},
|
|
53
|
+
"misto": set(), # url, email, path… hanno già regole proprie
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def forma_di(valori: list[str]) -> str:
|
|
58
|
+
"""Forma grossolana dei valori, per decidere se una promozione da nome è plausibile.
|
|
59
|
+
|
|
60
|
+
⚠️ La versione precedente aveva un difetto che annullava il canale proprio
|
|
61
|
+
dove serviva di più: classificava come CODICE qualunque valore senza spazi e
|
|
62
|
+
sotto i 24 caratteri. Ma `Milano`, `Roma`, `Rossi`, `Verdi` sono esattamente
|
|
63
|
+
così — quindi una colonna di città risultava «codice», `city` non è
|
|
64
|
+
compatibile con «codice», e il suggerimento dell'intestazione `Comune`
|
|
65
|
+
veniva **scartato**. Risultato: la colonna finiva `person_surname`, che è un
|
|
66
|
+
tipo personale, e gonfiava i falsi allarmi sui dati personali.
|
|
67
|
+
|
|
68
|
+
La discriminante giusta non è la lunghezza ma la COMPOSIZIONE: una parola di
|
|
69
|
+
sole lettere è testo anche se corta; un codice contiene cifre, oppure è una
|
|
70
|
+
sigla breve tutta maiuscola.
|
|
71
|
+
|
|
72
|
+
⚠️ Secondo difetto, stessa famiglia del primo e misurato sulle anagrafiche
|
|
73
|
+
italiane, dove i cognomi si scrivono TUTTI MAIUSCOLI. `ROSSI`, `RUSSO`,
|
|
74
|
+
`GRECO`, `CONTI`, `GALLO` sono cinque lettere maiuscole, cioè la definizione
|
|
75
|
+
di "sigla breve" data qui sopra: su dieci cognomi veri sei finivano fra i
|
|
76
|
+
codici e la colonna risultava «codice». Con `person_surname` incompatibile
|
|
77
|
+
con «codice», il suggerimento dell'intestazione `Cognome` veniva scartato di
|
|
78
|
+
nuovo — lo stesso guasto che questa funzione dichiara di aver corretto, sulla
|
|
79
|
+
forma più frequente del paese.
|
|
80
|
+
Una parola di cinque lettere maiuscole è però ambigua per davvero (`ACEM` è
|
|
81
|
+
una sigla, `ROSSI` un cognome): quindi ora **non vota**, e decidono i valori
|
|
82
|
+
non ambigui. Se sono ambigui tutti — una colonna di sole sigle brevi — vale
|
|
83
|
+
ancora «codice», che era il comportamento giusto per quel caso.
|
|
84
|
+
"""
|
|
85
|
+
if not valori:
|
|
86
|
+
return "misto"
|
|
87
|
+
n_num = n_cod = n_txt = n_amb = 0
|
|
88
|
+
for v in valori[:200]:
|
|
89
|
+
s = v.strip()
|
|
90
|
+
if not s:
|
|
91
|
+
continue
|
|
92
|
+
if re.fullmatch(r"[-+]?\d+([.,]\d+)?", s):
|
|
93
|
+
n_num += 1
|
|
94
|
+
elif re.fullmatch(r"[^\W\d_][\w'’\-. ]*", s, re.UNICODE) and not any(c.isdigit() for c in s):
|
|
95
|
+
# sole lettere (con spazi, apostrofi, trattini): è testo, corto o lungo
|
|
96
|
+
# che sia — a meno che non sia una sigla breve tutta maiuscola, che è
|
|
97
|
+
# indistinguibile da un cognome maiuscolo e quindi si astiene
|
|
98
|
+
if len(s) <= 5 and s.isupper():
|
|
99
|
+
n_amb += 1
|
|
100
|
+
else:
|
|
101
|
+
n_txt += 1
|
|
102
|
+
else:
|
|
103
|
+
n_cod += 1
|
|
104
|
+
deciding = n_num + n_cod + n_txt
|
|
105
|
+
tot = max(deciding, 1)
|
|
106
|
+
if n_num / tot > 0.7:
|
|
107
|
+
return "numerico"
|
|
108
|
+
if n_txt / tot > 0.5:
|
|
109
|
+
return "testo"
|
|
110
|
+
if n_cod / tot > 0.5:
|
|
111
|
+
return "codice"
|
|
112
|
+
if n_amb and deciding == 0:
|
|
113
|
+
return "codice"
|
|
114
|
+
return "misto"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Lessico multilingua: parola chiave -> tipo. Le chiavi sono confrontate sui
|
|
118
|
+
# TOKEN del nome normalizzato, non per sottostringa, altrimenti `id` pesca
|
|
119
|
+
# dentro `identificativo`, `residenza` e mezza tabella.
|
|
120
|
+
LESSICO: dict[str, tuple[str, ...]] = {
|
|
121
|
+
"person_name_full": ("nominativo", "nome_cognome", "nomecognome", "fullname",
|
|
122
|
+
"full_name", "denominazione_persona", "intestatario",
|
|
123
|
+
"richiedente", "titolare", "contatto", "persona"),
|
|
124
|
+
# ⚠️ QUI SI SOTTRAE, NON SI AGGIUNGE, ed è la correzione più redditizia del
|
|
125
|
+
# lessico. Misurato su 35.955 intestazioni reali: `nom*` mappato su
|
|
126
|
+
# `person_surname` dichiarava COGNOMI **647 colonne su 268 tabelle**, e quasi
|
|
127
|
+
# nessuna lo era. In francese e in catalano `nom` è il nome *di una cosa*, e
|
|
128
|
+
# `nom_<entità>` è lo schema di denominazione standard dei portali francesi e
|
|
129
|
+
# barcellonesi: `Nom_Barri` = "la Trinitat Nova", `nom_voie` = "Rue de
|
|
130
|
+
# Normandie", `nom_commune` = "Le Grès".
|
|
131
|
+
# Gemello opposto: in spagnolo `nombre` è "nome", ma in **francese è
|
|
132
|
+
# "numero"** — `nombre_de_reponses` con valori 58, 351 finiva fra i nomi
|
|
133
|
+
# propri. Due lingue, la stessa stringa, due significati incompatibili.
|
|
134
|
+
# La guardia sulla forma delle celle non ferma nulla, perché `person_surname`
|
|
135
|
+
# è compatibile con la forma `testo`: l'unico rimedio è togliere la voce.
|
|
136
|
+
# `prenom` resta (è inequivocabile), `nom_de_famille` copre il cognome francese.
|
|
137
|
+
"person_given": ("nome", "firstname", "first_name", "givenname", "given_name",
|
|
138
|
+
"prenom", "prénom", "vorname"),
|
|
139
|
+
"person_surname": ("cognome", "lastname", "last_name", "surname", "familyname",
|
|
140
|
+
"nom_de_famille", "nom_famille", "nachname", "apellido",
|
|
141
|
+
"apellidos", "cognom", "cognoms"),
|
|
142
|
+
"gender_label": ("sesso", "genere", "gender", "sex", "geschlecht", "sexe"),
|
|
143
|
+
"fiscal_code_it": ("codice_fiscale", "codicefiscale", "cod_fisc", "codfisc",
|
|
144
|
+
"cf", "c_f", "cfisc"),
|
|
145
|
+
"vat_number": ("partita_iva", "partitaiva", "piva", "p_iva", "vat", "iva",
|
|
146
|
+
"ust_id", "umsatzsteuer", "nif", "cif"),
|
|
147
|
+
"iban": ("iban", "conto_corrente", "coordinate_bancarie"),
|
|
148
|
+
"email": ("email", "e_mail", "mail", "pec", "posta_elettronica", "indirizzo_mail",
|
|
149
|
+
"courriel"),
|
|
150
|
+
"phone": ("telefono", "tel", "cellulare", "cell", "fax", "phone", "telefon",
|
|
151
|
+
"recapito_telefonico", "numero_telefono", "tel_fax"),
|
|
152
|
+
"street_address": ("indirizzo", "via", "ubicazione", "address", "strasse",
|
|
153
|
+
"adresse", "sede", "domicilio", "residenza", "toponimo",
|
|
154
|
+
"denominazione_via", "localizzazione", "strasse", "straße",
|
|
155
|
+
"nom_voie", "nom_carrer", "nom_rue", "carrer", "voie",
|
|
156
|
+
"adreca", "direccion"),
|
|
157
|
+
"city": ("citta", "comune", "city", "localita", "paese_citta", "stadt",
|
|
158
|
+
"ville", "municipio", "town", "comune_sede", "gemeinde", "ort",
|
|
159
|
+
"nom_commune", "nom_de_la_commune", "nom_com", "commune",
|
|
160
|
+
"ciudad", "poblacio", "municipi"),
|
|
161
|
+
"postcode": ("cap", "codice_postale", "zip", "postcode", "postal_code", "plz"),
|
|
162
|
+
"country_name": ("nazione", "paese", "stato", "country", "land", "pays",
|
|
163
|
+
"nazionalita", "cittadinanza"),
|
|
164
|
+
"country_code": ("cod_nazione", "country_code", "iso_paese", "sigla_nazione"),
|
|
165
|
+
"region_admin": ("regione", "provincia", "prov", "sigla_provincia", "distretto",
|
|
166
|
+
"region", "bundesland", "departement", "circoscrizione",
|
|
167
|
+
"municipio_zona", "area_statistica", "quartiere", "zona",
|
|
168
|
+
"sigla_prov", "provincia_sigla", "kreis", "landkreis",
|
|
169
|
+
"bezirk", "nom_reg", "nom_dept", "nom_dep", "nom_districte",
|
|
170
|
+
"nom_barri", "nom_epci", "districte", "barri", "departement",
|
|
171
|
+
"comarca", "provincia_nome"),
|
|
172
|
+
"company_name": ("ragione_sociale", "ragionesociale", "denominazione",
|
|
173
|
+
"impresa", "azienda", "ditta", "societa", "company",
|
|
174
|
+
"firmenname", "raison_sociale", "beneficiario", "fornitore",
|
|
175
|
+
"operatore_economico", "aggiudicatario", "insegna"),
|
|
176
|
+
"institution_name": ("ente", "istituto", "istituzione", "struttura", "scuola",
|
|
177
|
+
"universita", "ospedale", "amministrazione", "affiliazione",
|
|
178
|
+
"organizzazione", "organization", "einrichtung",
|
|
179
|
+
"nom_etablissement", "nom_equipament", "nom_du_lieu",
|
|
180
|
+
"equipament", "etablissement", "denominazione_ente"),
|
|
181
|
+
"job_title": ("qualifica", "mansione", "ruolo", "incarico", "carica",
|
|
182
|
+
"professione", "job_title", "position", "funzione"),
|
|
183
|
+
"legal_form": ("forma_giuridica", "natura_giuridica", "tipo_societa",
|
|
184
|
+
"legal_form", "rechtsform"),
|
|
185
|
+
"amount": ("importo", "valore", "costo", "prezzo", "spesa", "compenso",
|
|
186
|
+
"ammontare", "totale", "euro", "imponibile", "budget", "amount",
|
|
187
|
+
"betrag", "montant", "impegno", "pagato", "stanziamento",
|
|
188
|
+
"entrate", "uscite", "saldo", "retribuzione", "stipendio"),
|
|
189
|
+
"currency_code": ("valuta", "divisa", "currency"),
|
|
190
|
+
"date": ("data", "date", "datum", "giorno", "data_nascita", "datanascita",
|
|
191
|
+
"data_inizio", "data_fine", "scadenza", "decorrenza", "dat"),
|
|
192
|
+
"datetime": ("timestamp", "data_ora", "datetime", "rilevazione", "ora"),
|
|
193
|
+
"year": ("anno", "year", "esercizio", "annualita", "jahr", "annee"),
|
|
194
|
+
"url": ("url", "sito", "sito_web", "link", "web", "homepage", "pagina"),
|
|
195
|
+
# Il canale era capovolto: riconosceva `nord` ed `est` — che nelle tavole
|
|
196
|
+
# statistiche italiane sono MACRO-AREE (86 tabelle), non assi cartesiani — e
|
|
197
|
+
# non conosceva `latitude` e `longitude`, presenti in 88 tabelle ciascuna.
|
|
198
|
+
"coordinate": ("latitudine", "longitudine", "latitude", "longitude", "lat",
|
|
199
|
+
"lon", "lng", "long", "coordinata", "coord", "coordinates",
|
|
200
|
+
"x_coord", "y_coord", "breitengrad", "laengengrad",
|
|
201
|
+
"geo_point", "geo_point_2d", "punto_geo"),
|
|
202
|
+
"measurement": ("superficie", "area", "lunghezza", "larghezza", "altezza",
|
|
203
|
+
"peso", "volume", "distanza", "consumo", "potenza", "quantita",
|
|
204
|
+
"temperatura", "concentrazione", "valore_misura", "mq", "kmq",
|
|
205
|
+
"mc", "kwh", "kw", "metri", "posti", "capienza", "numero_posti",
|
|
206
|
+
"abitanti", "popolazione", "densita", "perimetro",
|
|
207
|
+
"anzahl", "ergebnis", "wert", "menge", "flaeche", "fläche",
|
|
208
|
+
"hoehe", "höhe", "gewicht", "verbrauch", "nombre_de",
|
|
209
|
+
"nombre_d", "effectif", "effectifs", "cantidad"),
|
|
210
|
+
"sequential_id": ("id", "codice_id", "progressivo", "numero", "num", "n",
|
|
211
|
+
"chiave", "key", "pk", "record", "riga", "seq", "objectid",
|
|
212
|
+
"fid", "gid", "cod_id"),
|
|
213
|
+
# `code`, `type`, `label` valgono ~3.270 colonne su ~700 tabelle: erano
|
|
214
|
+
# coperte solo nelle forme italiane. `name` NON entra qui e NON va su
|
|
215
|
+
# `person_*`: nei portali è il nome di una cosa, esattamente come `nom`.
|
|
216
|
+
"enum_code": ("code", "type", "label", "codigo", "codi", "tipus", "art",
|
|
217
|
+
"kategorie", "klasse", "schluessel", "schlüssel", "merkmal",
|
|
218
|
+
"auspraegung", "ausprägung", "statistik",
|
|
219
|
+
"codice", "cod", "sigla", "tipo", "tipologia", "categoria",
|
|
220
|
+
"classe", "stato", "status", "livello", "grado", "settore",
|
|
221
|
+
"ateco", "cpv", "capitolo", "voce", "gruppo"),
|
|
222
|
+
"boolean": ("flag", "attivo", "abilitato", "presente", "disponibile",
|
|
223
|
+
"is_", "ha_", "si_no"),
|
|
224
|
+
"icd_code": ("diagnosi", "icd", "patologia", "codice_diagnosi"),
|
|
225
|
+
"atc_code": ("atc", "farmaco", "principio_attivo"),
|
|
226
|
+
"free_text": ("beschreibung", "bezeichnung", "bemerkung", "hinweis",
|
|
227
|
+
"description", "descripcion", "descripcio", "commentaire",
|
|
228
|
+
"note", "descrizione", "descr", "oggetto", "annotazioni",
|
|
229
|
+
"commento", "osservazioni", "motivazione", "dettaglio",
|
|
230
|
+
"testo", "avvertenze", "note_agg"),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# indice inverso token -> tipi
|
|
234
|
+
_IDX: dict[str, list[str]] = {}
|
|
235
|
+
for _t, _kk in LESSICO.items():
|
|
236
|
+
for _k in _kk:
|
|
237
|
+
_IDX.setdefault(_k, []).append(_t)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def normalizza(nome: str) -> list[str]:
|
|
241
|
+
s = unicodedata.normalize("NFKD", str(nome or ""))
|
|
242
|
+
s = "".join(c for c in s if not unicodedata.combining(c)).casefold()
|
|
243
|
+
s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
|
|
244
|
+
if not s:
|
|
245
|
+
return []
|
|
246
|
+
tok = [t for t in s.split("_") if t]
|
|
247
|
+
# oltre ai token singoli, le coppie adiacenti: `data_nascita`, `ragione_sociale`
|
|
248
|
+
coppie = ["_".join(tok[i:i + 2]) for i in range(len(tok) - 1)]
|
|
249
|
+
triple = ["_".join(tok[i:i + 3]) for i in range(len(tok) - 2)]
|
|
250
|
+
return [s] + triple + coppie + tok
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def prior(nome: str) -> dict[str, float]:
|
|
254
|
+
"""Punteggio per tipo a partire dal nome della colonna.
|
|
255
|
+
|
|
256
|
+
Le corrispondenze più lunghe pesano di più: `data_nascita` deve battere
|
|
257
|
+
`data`, e `codice_fiscale` deve battere `codice`.
|
|
258
|
+
"""
|
|
259
|
+
out: dict[str, float] = {}
|
|
260
|
+
if not nome:
|
|
261
|
+
return out
|
|
262
|
+
for frammento in normalizza(nome):
|
|
263
|
+
for t in _IDX.get(frammento, ()):
|
|
264
|
+
peso = 1.0 + 0.6 * frammento.count("_")
|
|
265
|
+
out[t] = max(out.get(t, 0.0), peso)
|
|
266
|
+
if not out:
|
|
267
|
+
return out
|
|
268
|
+
top = max(out.values())
|
|
269
|
+
return {k: v / top for k, v in out.items()}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def compatibile(tipo: str, forma: str) -> bool:
|
|
273
|
+
"""Il nome può promuovere `tipo` solo se la forma delle celle lo consente."""
|
|
274
|
+
if forma == "misto":
|
|
275
|
+
return True
|
|
276
|
+
return tipo in FORME.get(forma, set())
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# ---------------------------------------------------------------------------
|
|
2
|
+
# VENDORED FILE — DO NOT EDIT HERE.
|
|
3
|
+
# Source of truth: backend/colscan/taxonomy.py in the private repo
|
|
4
|
+
# sheppard94g/dbtool-platform, commit f3fe522b09244d013d59de7ebd5a6af38a143de1.
|
|
5
|
+
# Regenerate with: python tools/sync_engine.py --write
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
"""Tassonomia dei tipi semantici di cella per colscan.
|
|
8
|
+
|
|
9
|
+
Un solo posto in cui i tipi sono definiti: generatori, validatori, addestramento
|
|
10
|
+
e valutazione importano tutti da qui. Se un tipo non è in questa lista, non
|
|
11
|
+
esiste — è la contromisura al rischio "la tassonomia esplode" dichiarato nello
|
|
12
|
+
spec (docs/superpowers/specs/2026-08-16-column-classifier-design.md, §4).
|
|
13
|
+
|
|
14
|
+
Ogni tipo dichiara:
|
|
15
|
+
family famiglia di appartenenza (per il rendiconto aggregato)
|
|
16
|
+
personal se un valore di questo tipo è di per sé un dato personale
|
|
17
|
+
(art. 4(1) GDPR) — vedi la nota sotto
|
|
18
|
+
special se ricade nelle categorie particolari (art. 9 GDPR)
|
|
19
|
+
regular se il tipo ha una forma verificabile in modo deterministico.
|
|
20
|
+
I tipi `regular=True` sono di competenza dei VALIDATORI, non
|
|
21
|
+
della rete: per loro una regola con cifra di controllo ha
|
|
22
|
+
precisione praticamente perfetta ed è spiegabile a un auditor.
|
|
23
|
+
La rete serve dove `regular=False`.
|
|
24
|
+
|
|
25
|
+
NOTA sul campo `personal`. È una proprietà del *valore isolato*, non della
|
|
26
|
+
colonna nel suo contesto: un IBAN è un dato personale se riferito a una persona
|
|
27
|
+
fisica e non lo è se riferito a una società. Il campo qui dice "questo tipo può
|
|
28
|
+
identificare o riferirsi a una persona fisica", cioè fa scattare la verifica, e
|
|
29
|
+
NON è un giudizio giuridico. Lo strumento segnala; la qualificazione la fa chi
|
|
30
|
+
tiene il registro dei trattamenti.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class TypeSpec:
|
|
38
|
+
name: str
|
|
39
|
+
family: str
|
|
40
|
+
personal: bool
|
|
41
|
+
special: bool
|
|
42
|
+
regular: bool
|
|
43
|
+
desc: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_T = [
|
|
47
|
+
# ---- identity -----------------------------------------------------------
|
|
48
|
+
TypeSpec("person_name_full", "identity", True, False, False, "Nome e cognome insieme"),
|
|
49
|
+
TypeSpec("person_given", "identity", True, False, False, "Solo il nome proprio"),
|
|
50
|
+
TypeSpec("person_surname", "identity", True, False, False, "Solo il cognome"),
|
|
51
|
+
TypeSpec("gender_label", "identity", True, False, True, "M/F/maschio/female/1/2..."),
|
|
52
|
+
TypeSpec("fiscal_code_it", "identity", True, False, True, "Codice fiscale italiano"),
|
|
53
|
+
TypeSpec("national_id", "identity", True, False, True, "Documento/ID nazionale non italiano"),
|
|
54
|
+
# ---- contact ------------------------------------------------------------
|
|
55
|
+
TypeSpec("email", "contact", True, False, True, "Indirizzo email"),
|
|
56
|
+
TypeSpec("phone", "contact", True, False, True, "Numero di telefono"),
|
|
57
|
+
TypeSpec("street_address", "contact", True, False, False, "Via e civico"),
|
|
58
|
+
# Le anagrafiche vere separano spessissimo la via dal civico in due colonne
|
|
59
|
+
# (`Civico`, `CIVICO`, `civico`: 4 colonne su 700 nel banco reale). Senza
|
|
60
|
+
# questo tipo il civico cade in `sequential_id`, cioè in un tipo NON
|
|
61
|
+
# personale — e una colonna di civici accostata a una di vie è parte di un
|
|
62
|
+
# indirizzo, quindi dato personale. Era l'errore più costoso dei cinque.
|
|
63
|
+
TypeSpec("house_number", "contact", True, False, True, "Numero civico isolato"),
|
|
64
|
+
TypeSpec("city", "contact", False, False, False, "Nome di città"),
|
|
65
|
+
TypeSpec("postcode", "contact", False, False, True, "CAP / codice postale"),
|
|
66
|
+
TypeSpec("country_name", "contact", False, False, False, "Nome di paese"),
|
|
67
|
+
TypeSpec("country_code", "contact", False, False, True, "ISO 3166 alpha-2/3"),
|
|
68
|
+
TypeSpec("region_admin", "contact", False, False, False, "Regione/provincia/stato"),
|
|
69
|
+
# Continente e macro-area (ripartizione geografica) non sono una regione:
|
|
70
|
+
# confonderli fa sbagliare il livello di aggregazione a chi legge il dato.
|
|
71
|
+
# Vocabolario chiuso di qualche decina di voci, quindi competenza della regola.
|
|
72
|
+
TypeSpec("continent", "contact", False, False, True, "Continente o macro-area geografica"),
|
|
73
|
+
TypeSpec("coordinate", "contact", False, False, True, "Latitudine/longitudine"),
|
|
74
|
+
# ---- financial ----------------------------------------------------------
|
|
75
|
+
TypeSpec("iban", "financial", True, False, True, "IBAN (checksum mod-97)"),
|
|
76
|
+
TypeSpec("bic", "financial", False, False, True, "BIC/SWIFT"),
|
|
77
|
+
TypeSpec("credit_card", "financial", True, False, True, "Carta di pagamento (Luhn)"),
|
|
78
|
+
TypeSpec("vat_number", "financial", False, False, True, "Partita IVA / VAT"),
|
|
79
|
+
TypeSpec("amount", "financial", False, False, True, "Importo monetario"),
|
|
80
|
+
TypeSpec("currency_code", "financial", False, False, True, "ISO 4217"),
|
|
81
|
+
# ---- org ----------------------------------------------------------------
|
|
82
|
+
TypeSpec("company_name", "org", False, False, False, "Ragione sociale"),
|
|
83
|
+
TypeSpec("institution_name", "org", False, False, False, "Ente/istituzione/affiliazione"),
|
|
84
|
+
TypeSpec("job_title", "org", False, False, False, "Qualifica professionale"),
|
|
85
|
+
TypeSpec("legal_form", "org", False, False, False, "Forma giuridica (S.p.A., GmbH...)"),
|
|
86
|
+
# ---- health (art. 9) ----------------------------------------------------
|
|
87
|
+
TypeSpec("icd_code", "health", True, True, True, "Codice diagnosi ICD-9/ICD-10"),
|
|
88
|
+
TypeSpec("atc_code", "health", True, True, True, "Codice farmaco ATC"),
|
|
89
|
+
# ---- temporal -----------------------------------------------------------
|
|
90
|
+
TypeSpec("date", "temporal", False, False, True, "Data"),
|
|
91
|
+
TypeSpec("datetime", "temporal", False, False, True, "Data e ora"),
|
|
92
|
+
TypeSpec("year", "temporal", False, False, True, "Anno"),
|
|
93
|
+
# `2026-W33`, `2026Q3`, `2026-08`: un PERIODO, non un istante. In
|
|
94
|
+
# epidemiologia e nelle serie storiche è la chiave temporale normale (la
|
|
95
|
+
# colonna `year_week` del banco reale è settimana ISO). Chiamarlo `date`
|
|
96
|
+
# è un errore di semantica, non di formato: una data si ordina e si
|
|
97
|
+
# sottrae, un periodo si aggrega.
|
|
98
|
+
TypeSpec("period", "temporal", False, False, True, "Settimana ISO, trimestre, mese"),
|
|
99
|
+
# POI, farmacie, musei, sportelli: 7 colonne su 700 nel banco reale, una
|
|
100
|
+
# per giorno della settimana. Nessun tipo esistente ci si avvicinava.
|
|
101
|
+
TypeSpec("opening_hours", "temporal", False, False, True, "Orario di apertura"),
|
|
102
|
+
# ---- technical ----------------------------------------------------------
|
|
103
|
+
TypeSpec("url", "technical", False, False, True, "URL"),
|
|
104
|
+
TypeSpec("domain", "technical", False, False, True, "Nome a dominio"),
|
|
105
|
+
TypeSpec("ip_address", "technical", False, False, True, "IPv4 o IPv6"),
|
|
106
|
+
TypeSpec("mac_address", "technical", False, False, True, "Indirizzo MAC"),
|
|
107
|
+
TypeSpec("uuid", "technical", False, False, True, "UUID"),
|
|
108
|
+
TypeSpec("hash_hex", "technical", False, False, True, "Digest esadecimale"),
|
|
109
|
+
TypeSpec("sequential_id", "technical", False, False, True, "Contatore/chiave numerica"),
|
|
110
|
+
TypeSpec("enum_code", "technical", False, False, False, "Codice breve da vocabolario chiuso"),
|
|
111
|
+
# Il tipo che svuota la discarica. `enum_code` è il tipo più frequente del
|
|
112
|
+
# banco reale (174 colonne su 700) e ci finisce dentro tutto ciò che è
|
|
113
|
+
# "corto e non riconosciuto". Ma CIG, CUP, codice catastale, codice ISTAT,
|
|
114
|
+
# codice meccanografico, codice IPA e ATECO NON sono vocabolari chiusi
|
|
115
|
+
# arbitrari: hanno una forma propria e un elenco ufficiale pubblicato, cioè
|
|
116
|
+
# sono competenza dei validatori. Stessa famiglia di `enum_code` di
|
|
117
|
+
# proposito, perché è da lì che vengono e perché sbagliare fra i due resta
|
|
118
|
+
# un errore veniale nel rendiconto per famiglia.
|
|
119
|
+
TypeSpec("domain_identifier", "technical", False, False, True,
|
|
120
|
+
"Identificativo di dominio verificabile (CIG, CUP, catastale, ISTAT, ATECO…)"),
|
|
121
|
+
TypeSpec("boolean", "technical", False, False, True, "Vero/falso"),
|
|
122
|
+
TypeSpec("file_path", "technical", False, False, True, "Percorso o nome di file"),
|
|
123
|
+
# ---- other --------------------------------------------------------------
|
|
124
|
+
TypeSpec("measurement", "other", False, False, True, "Quantità con o senza unità"),
|
|
125
|
+
TypeSpec("free_text", "other", False, False, False, "Testo libero"),
|
|
126
|
+
TypeSpec("unknown", "other", False, False, False, "Vuoto, segnaposto o non riconosciuto"),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
TYPES = {t.name: t for t in _T}
|
|
130
|
+
LABELS = [t.name for t in _T]
|
|
131
|
+
LABEL_TO_ID = {n: i for i, n in enumerate(LABELS)}
|
|
132
|
+
N_LABELS = len(LABELS)
|
|
133
|
+
|
|
134
|
+
FAMILIES = sorted({t.family for t in _T})
|
|
135
|
+
REGULAR = [t.name for t in _T if t.regular]
|
|
136
|
+
IRREGULAR = [t.name for t in _T if not t.regular]
|
|
137
|
+
PERSONAL = [t.name for t in _T if t.personal]
|
|
138
|
+
SPECIAL = [t.name for t in _T if t.special]
|
|
139
|
+
|
|
140
|
+
# I tipi su cui si decide se il modello merita di esistere. Sui tipi `regular`
|
|
141
|
+
# la linea di base deterministica vince per costruzione e va usata quella: un
|
|
142
|
+
# modello che li reimpara spende settimane per peggiorare il risultato.
|
|
143
|
+
DECIDING = [t for t in IRREGULAR if t != "unknown"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def describe() -> str:
|
|
147
|
+
out = [f"{N_LABELS} tipi, {len(FAMILIES)} famiglie",
|
|
148
|
+
f" regolari (competenza dei validatori): {len(REGULAR)}",
|
|
149
|
+
f" irregolari (competenza della rete): {len(IRREGULAR)}",
|
|
150
|
+
f" dato personale: {len(PERSONAL)} · categoria particolare art.9: {len(SPECIAL)}",
|
|
151
|
+
"", "Tipi su cui si giudica il modello (criterio di arresto):",
|
|
152
|
+
" " + ", ".join(DECIDING)]
|
|
153
|
+
return "\n".join(out)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
print(describe())
|