tejtm 0.1.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.
- tejtm/__init__.py +30 -0
- tejtm/assignment.py +65 -0
- tejtm/coercion.py +50 -0
- tejtm/engine.py +116 -0
- tejtm/models.py +138 -0
- tejtm/normalize.py +44 -0
- tejtm/pydantic_adapter.py +106 -0
- tejtm/scorers/__init__.py +22 -0
- tejtm/scorers/base.py +34 -0
- tejtm/scorers/exact.py +19 -0
- tejtm/scorers/fuzzy.py +28 -0
- tejtm/scorers/header_embedding.py +69 -0
- tejtm/scorers/semantic_type.py +31 -0
- tejtm/scorers/value_intent.py +70 -0
- tejtm/serialization.py +22 -0
- tejtm/typing/__init__.py +3 -0
- tejtm/typing/detectors.py +113 -0
- tejtm/value_profile.py +108 -0
- tejtm-0.1.0.dist-info/METADATA +113 -0
- tejtm-0.1.0.dist-info/RECORD +21 -0
- tejtm-0.1.0.dist-info/WHEEL +4 -0
tejtm/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from tejtm.coercion import coerce_record, coerce_value
|
|
2
|
+
from tejtm.engine import MappingEngine
|
|
3
|
+
from tejtm.models import (
|
|
4
|
+
DEFAULT_WEIGHTS,
|
|
5
|
+
FieldMapping,
|
|
6
|
+
MappingConfig,
|
|
7
|
+
MappingResult,
|
|
8
|
+
ScorerResult,
|
|
9
|
+
SourceColumn,
|
|
10
|
+
SourceProfile,
|
|
11
|
+
TargetField,
|
|
12
|
+
TargetSchema,
|
|
13
|
+
TypeGuess,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"coerce_record",
|
|
18
|
+
"coerce_value",
|
|
19
|
+
"DEFAULT_WEIGHTS",
|
|
20
|
+
"FieldMapping",
|
|
21
|
+
"MappingConfig",
|
|
22
|
+
"MappingEngine",
|
|
23
|
+
"MappingResult",
|
|
24
|
+
"ScorerResult",
|
|
25
|
+
"SourceColumn",
|
|
26
|
+
"SourceProfile",
|
|
27
|
+
"TargetField",
|
|
28
|
+
"TargetSchema",
|
|
29
|
+
"TypeGuess",
|
|
30
|
+
]
|
tejtm/assignment.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def assign_one_to_one(
|
|
5
|
+
scores: list[list[float]],
|
|
6
|
+
min_confidence: float,
|
|
7
|
+
) -> list[tuple[int, int] | None]:
|
|
8
|
+
"""Return (row, col) per row index, or None when unassigned."""
|
|
9
|
+
if not scores:
|
|
10
|
+
return []
|
|
11
|
+
n_rows = len(scores)
|
|
12
|
+
n_cols = len(scores[0]) if scores else 0
|
|
13
|
+
if n_cols == 0:
|
|
14
|
+
return [None] * n_rows
|
|
15
|
+
|
|
16
|
+
pairs = _hungarian_maximize(scores)
|
|
17
|
+
result: list[tuple[int, int] | None] = [None] * n_rows
|
|
18
|
+
for row, col in pairs:
|
|
19
|
+
if scores[row][col] >= min_confidence:
|
|
20
|
+
result[row] = (row, col)
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hungarian_maximize(scores: list[list[float]]) -> list[tuple[int, int]]:
|
|
25
|
+
n_rows = len(scores)
|
|
26
|
+
n_cols = len(scores[0])
|
|
27
|
+
n = max(n_rows, n_cols)
|
|
28
|
+
cost = [[1.0 - scores[i][j] if i < n_rows and j < n_cols else 1.0 for j in range(n)] for i in range(n)]
|
|
29
|
+
assignment = _linear_sum_assignment(cost)
|
|
30
|
+
out: list[tuple[int, int]] = []
|
|
31
|
+
for i, j in assignment:
|
|
32
|
+
if i < n_rows and j < n_cols:
|
|
33
|
+
out.append((i, j))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _linear_sum_assignment(cost: list[list[float]]) -> list[tuple[int, int]]:
|
|
38
|
+
try:
|
|
39
|
+
from scipy.optimize import linear_sum_assignment # type: ignore import-not-found
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
row_ind, col_ind = linear_sum_assignment(np.array(cost))
|
|
44
|
+
return [(int(r), int(c)) for r, c in zip(row_ind, col_ind, strict=True)]
|
|
45
|
+
except ImportError:
|
|
46
|
+
return _greedy_maximize(cost)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _greedy_maximize(cost: list[list[float]]) -> list[tuple[int, int]]:
|
|
50
|
+
n = len(cost)
|
|
51
|
+
candidates: list[tuple[float, int, int]] = []
|
|
52
|
+
for i in range(n):
|
|
53
|
+
for j in range(n):
|
|
54
|
+
candidates.append((cost[i][j], i, j))
|
|
55
|
+
candidates.sort(key=lambda t: t[0])
|
|
56
|
+
used_rows: set[int] = set()
|
|
57
|
+
used_cols: set[int] = set()
|
|
58
|
+
pairs: list[tuple[int, int]] = []
|
|
59
|
+
for _, i, j in candidates:
|
|
60
|
+
if i in used_rows or j in used_cols:
|
|
61
|
+
continue
|
|
62
|
+
used_rows.add(i)
|
|
63
|
+
used_cols.add(j)
|
|
64
|
+
pairs.append((i, j))
|
|
65
|
+
return pairs
|
tejtm/coercion.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from tejtm.typing.detectors import detect_semantic_type
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def coerce_value(value: Any, semantic_type: str) -> Any:
|
|
10
|
+
if value is None or value == "":
|
|
11
|
+
return None
|
|
12
|
+
text = str(value).strip()
|
|
13
|
+
if semantic_type == "boolean":
|
|
14
|
+
low = text.casefold()
|
|
15
|
+
if low in ("true", "yes", "y", "1"):
|
|
16
|
+
return True
|
|
17
|
+
if low in ("false", "no", "n", "0"):
|
|
18
|
+
return False
|
|
19
|
+
return text
|
|
20
|
+
if semantic_type == "integer":
|
|
21
|
+
return int(text.replace(",", ""))
|
|
22
|
+
if semantic_type == "number" or semantic_type == "currency":
|
|
23
|
+
return float(text.replace(",", "").replace("$", "").replace("€", "").replace("£", ""))
|
|
24
|
+
if semantic_type == "date":
|
|
25
|
+
return date.fromisoformat(text[:10])
|
|
26
|
+
if semantic_type == "datetime":
|
|
27
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00")[:19])
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def coerce_record(record: Mapping[str, Any], field_types: Mapping[str, str]) -> dict[str, Any]:
|
|
32
|
+
out: dict[str, Any] = {}
|
|
33
|
+
for key, value in record.items():
|
|
34
|
+
stype = field_types.get(key)
|
|
35
|
+
if stype is None:
|
|
36
|
+
out[key] = value
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
out[key] = coerce_value(value, stype)
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
out[key] = value
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def infer_field_types_from_samples(
|
|
46
|
+
records: list[Mapping[str, Any]],
|
|
47
|
+
column: str,
|
|
48
|
+
) -> str:
|
|
49
|
+
samples = [str(r.get(column, "")) for r in records if r.get(column) is not None]
|
|
50
|
+
return detect_semantic_type(samples).label
|
tejtm/engine.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tejtm.assignment import assign_one_to_one
|
|
4
|
+
from tejtm.models import (
|
|
5
|
+
FieldMapping,
|
|
6
|
+
MappingConfig,
|
|
7
|
+
MappingResult,
|
|
8
|
+
SourceColumn,
|
|
9
|
+
SourceProfile,
|
|
10
|
+
TargetField,
|
|
11
|
+
TargetSchema,
|
|
12
|
+
)
|
|
13
|
+
from tejtm.scorers import DEFAULT_SCORERS
|
|
14
|
+
from tejtm.scorers.base import Scorer, ScoringContext, aggregate_scores
|
|
15
|
+
from tejtm.scorers.header_embedding import HeaderEmbeddingScorer, _get_embedder
|
|
16
|
+
from tejtm.typing.detectors import detect_semantic_type
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MappingEngine:
|
|
20
|
+
def __init__(self, scorers: tuple[Scorer, ...] | None = None) -> None:
|
|
21
|
+
self._scorers = scorers if scorers is not None else DEFAULT_SCORERS
|
|
22
|
+
|
|
23
|
+
def propose_mappings(
|
|
24
|
+
self,
|
|
25
|
+
source: SourceProfile,
|
|
26
|
+
target: TargetSchema,
|
|
27
|
+
*,
|
|
28
|
+
config: MappingConfig | None = None,
|
|
29
|
+
) -> MappingResult:
|
|
30
|
+
cfg = config or MappingConfig()
|
|
31
|
+
column_types = {col.name: detect_semantic_type(col.samples) for col in source.columns}
|
|
32
|
+
field_embeddings: dict[str, tuple[float, ...]] | None = {} if _get_embedder() else None
|
|
33
|
+
context = ScoringContext(
|
|
34
|
+
config=cfg,
|
|
35
|
+
column_types=column_types,
|
|
36
|
+
field_embeddings=field_embeddings,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
fields = list(target.fields)
|
|
40
|
+
columns = list(source.columns)
|
|
41
|
+
field_ids = [f.id for f in fields]
|
|
42
|
+
|
|
43
|
+
override_mappings: list[FieldMapping] = []
|
|
44
|
+
override_sources: set[str] = set()
|
|
45
|
+
override_targets: set[str] = set()
|
|
46
|
+
for src_name, tgt_id in cfg.overrides.items():
|
|
47
|
+
if any(c.name == src_name for c in columns) and tgt_id in field_ids:
|
|
48
|
+
override_mappings.append(
|
|
49
|
+
FieldMapping(
|
|
50
|
+
source_column=src_name,
|
|
51
|
+
target_field=tgt_id,
|
|
52
|
+
confidence=1.0,
|
|
53
|
+
breakdown=(),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
override_sources.add(src_name)
|
|
57
|
+
override_targets.add(tgt_id)
|
|
58
|
+
|
|
59
|
+
score_matrix: list[list[float]] = []
|
|
60
|
+
breakdowns: list[list[tuple]] = []
|
|
61
|
+
for col in columns:
|
|
62
|
+
row_scores: list[float] = []
|
|
63
|
+
row_breakdowns: list[tuple] = []
|
|
64
|
+
for fld in fields:
|
|
65
|
+
parts = self._score_pair(col, fld, context)
|
|
66
|
+
conf = aggregate_scores(parts)
|
|
67
|
+
row_scores.append(conf)
|
|
68
|
+
row_breakdowns.append(tuple(parts))
|
|
69
|
+
score_matrix.append(row_scores)
|
|
70
|
+
breakdowns.append(row_breakdowns)
|
|
71
|
+
|
|
72
|
+
assignments = assign_one_to_one(score_matrix, cfg.min_confidence)
|
|
73
|
+
mappings: list[FieldMapping] = list(override_mappings)
|
|
74
|
+
mapped_sources = set(override_sources)
|
|
75
|
+
mapped_targets = set(override_targets)
|
|
76
|
+
|
|
77
|
+
for row_idx, pair in enumerate(assignments):
|
|
78
|
+
if pair is None:
|
|
79
|
+
continue
|
|
80
|
+
col = columns[row_idx]
|
|
81
|
+
if col.name in mapped_sources:
|
|
82
|
+
continue
|
|
83
|
+
_, col_idx = pair
|
|
84
|
+
fld = fields[col_idx]
|
|
85
|
+
if fld.id in mapped_targets:
|
|
86
|
+
continue
|
|
87
|
+
conf = score_matrix[row_idx][col_idx]
|
|
88
|
+
mappings.append(
|
|
89
|
+
FieldMapping(
|
|
90
|
+
source_column=col.name,
|
|
91
|
+
target_field=fld.id,
|
|
92
|
+
confidence=conf,
|
|
93
|
+
breakdown=breakdowns[row_idx][col_idx],
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
mapped_sources.add(col.name)
|
|
97
|
+
mapped_targets.add(fld.id)
|
|
98
|
+
|
|
99
|
+
unmapped = tuple(c.name for c in columns if c.name not in mapped_sources)
|
|
100
|
+
unmatched = tuple(f.id for f in fields if f.id not in mapped_targets)
|
|
101
|
+
mappings.sort(key=lambda m: (-m.confidence, m.source_column))
|
|
102
|
+
return MappingResult(
|
|
103
|
+
mappings=tuple(mappings),
|
|
104
|
+
unmapped_columns=unmapped,
|
|
105
|
+
unmatched_fields=unmatched,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _score_pair(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> list:
|
|
109
|
+
parts = []
|
|
110
|
+
for scorer in self._scorers:
|
|
111
|
+
if isinstance(scorer, HeaderEmbeddingScorer) and context.field_embeddings is None:
|
|
112
|
+
continue
|
|
113
|
+
result = scorer.score(column, field, context)
|
|
114
|
+
if result is not None:
|
|
115
|
+
parts.append(result)
|
|
116
|
+
return parts
|
tejtm/models.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Mapping, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class SourceColumn:
|
|
9
|
+
name: str
|
|
10
|
+
samples: Sequence[str] = ()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class SourceProfile:
|
|
15
|
+
columns: Sequence[SourceColumn]
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
names = [c.name for c in self.columns]
|
|
19
|
+
if len(names) != len(set(names)):
|
|
20
|
+
raise ValueError("SourceProfile column names must be unique")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class TargetField:
|
|
25
|
+
id: str
|
|
26
|
+
labels: Sequence[str] = ()
|
|
27
|
+
aliases: Sequence[str] = ()
|
|
28
|
+
expected_semantic_type: str | None = None
|
|
29
|
+
description: str | None = None
|
|
30
|
+
exemplar_samples: Sequence[str] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class TargetSchema:
|
|
35
|
+
fields: Sequence[TargetField]
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
ids = [f.id for f in self.fields]
|
|
39
|
+
if len(ids) != len(set(ids)):
|
|
40
|
+
raise ValueError("TargetSchema field ids must be unique")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class TypeGuess:
|
|
45
|
+
label: str
|
|
46
|
+
confidence: float
|
|
47
|
+
evidence: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class ScorerResult:
|
|
52
|
+
scorer: str
|
|
53
|
+
score: float
|
|
54
|
+
weight: float
|
|
55
|
+
reason: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class FieldMapping:
|
|
60
|
+
source_column: str
|
|
61
|
+
target_field: str
|
|
62
|
+
confidence: float
|
|
63
|
+
breakdown: tuple[ScorerResult, ...] = ()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class MappingResult:
|
|
68
|
+
mappings: tuple[FieldMapping, ...]
|
|
69
|
+
unmapped_columns: tuple[str, ...]
|
|
70
|
+
unmatched_fields: tuple[str, ...]
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, Any]:
|
|
73
|
+
return {
|
|
74
|
+
"mappings": [
|
|
75
|
+
{
|
|
76
|
+
"source_column": m.source_column,
|
|
77
|
+
"target_field": m.target_field,
|
|
78
|
+
"confidence": m.confidence,
|
|
79
|
+
"breakdown": [
|
|
80
|
+
{
|
|
81
|
+
"scorer": b.scorer,
|
|
82
|
+
"score": b.score,
|
|
83
|
+
"weight": b.weight,
|
|
84
|
+
"reason": b.reason,
|
|
85
|
+
}
|
|
86
|
+
for b in m.breakdown
|
|
87
|
+
],
|
|
88
|
+
}
|
|
89
|
+
for m in self.mappings
|
|
90
|
+
],
|
|
91
|
+
"unmapped_columns": list(self.unmapped_columns),
|
|
92
|
+
"unmatched_fields": list(self.unmatched_fields),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, data: Mapping[str, Any]) -> MappingResult:
|
|
97
|
+
mappings = tuple(
|
|
98
|
+
FieldMapping(
|
|
99
|
+
source_column=m["source_column"],
|
|
100
|
+
target_field=m["target_field"],
|
|
101
|
+
confidence=float(m["confidence"]),
|
|
102
|
+
breakdown=tuple(
|
|
103
|
+
ScorerResult(
|
|
104
|
+
scorer=b["scorer"],
|
|
105
|
+
score=float(b["score"]),
|
|
106
|
+
weight=float(b["weight"]),
|
|
107
|
+
reason=b.get("reason", ""),
|
|
108
|
+
)
|
|
109
|
+
for b in m.get("breakdown", [])
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
for m in data.get("mappings", [])
|
|
113
|
+
)
|
|
114
|
+
return cls(
|
|
115
|
+
mappings=mappings,
|
|
116
|
+
unmapped_columns=tuple(data.get("unmapped_columns", [])),
|
|
117
|
+
unmatched_fields=tuple(data.get("unmatched_fields", [])),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class MappingConfig:
|
|
123
|
+
weights: Mapping[str, float] = field(default_factory=dict)
|
|
124
|
+
min_confidence: float = 0.55
|
|
125
|
+
intent_margin: float = 0.08
|
|
126
|
+
overrides: Mapping[str, str] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
def weight(self, scorer: str, default: float) -> float:
|
|
129
|
+
return self.weights.get(scorer, default)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
|
133
|
+
"exact_name": 1.0,
|
|
134
|
+
"fuzzy_name": 0.45,
|
|
135
|
+
"semantic_type": 0.25,
|
|
136
|
+
"header_embedding": 0.20,
|
|
137
|
+
"value_intent": 0.30,
|
|
138
|
+
}
|
tejtm/normalize.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unicodedata
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
6
|
+
_NON_ALNUM = re.compile(r"[^\w\s]+", re.UNICODE)
|
|
7
|
+
_WS = re.compile(r"\s+")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_identifier(text: str) -> str:
|
|
11
|
+
if not text:
|
|
12
|
+
return ""
|
|
13
|
+
text = unicodedata.normalize("NFKC", text)
|
|
14
|
+
text = text.strip().casefold()
|
|
15
|
+
text = _NON_ALNUM.sub(" ", text)
|
|
16
|
+
text = _WS.sub(" ", text).strip()
|
|
17
|
+
return text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tokenize_identifier(text: str) -> tuple[str, ...]:
|
|
21
|
+
if not text:
|
|
22
|
+
return ()
|
|
23
|
+
raw = unicodedata.normalize("NFKC", text.strip())
|
|
24
|
+
raw = _NON_ALNUM.sub(" ", raw)
|
|
25
|
+
parts: list[str] = []
|
|
26
|
+
for chunk in _WS.split(raw):
|
|
27
|
+
if not chunk:
|
|
28
|
+
continue
|
|
29
|
+
for piece in _CAMEL_BOUNDARY.sub(" ", chunk.replace("_", " ")).split():
|
|
30
|
+
piece = normalize_identifier(piece)
|
|
31
|
+
if piece:
|
|
32
|
+
parts.append(piece)
|
|
33
|
+
return tuple(parts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def candidate_names(field_id: str, labels: Sequence[str], aliases: Sequence[str]) -> tuple[str, ...]:
|
|
37
|
+
seen: set[str] = set()
|
|
38
|
+
out: list[str] = []
|
|
39
|
+
for raw in (field_id, *labels, *aliases):
|
|
40
|
+
norm = normalize_identifier(raw)
|
|
41
|
+
if norm and norm not in seen:
|
|
42
|
+
seen.add(norm)
|
|
43
|
+
out.append(norm)
|
|
44
|
+
return tuple(out)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping, Sequence, TypeVar
|
|
5
|
+
|
|
6
|
+
from tejtm.coercion import coerce_record
|
|
7
|
+
from tejtm.engine import MappingEngine
|
|
8
|
+
from tejtm.models import MappingConfig, MappingResult, SourceColumn, SourceProfile, TargetField, TargetSchema
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ImportResult:
|
|
15
|
+
valid_rows: list[Any]
|
|
16
|
+
errors: list[dict[str, Any]]
|
|
17
|
+
mapping: MappingResult
|
|
18
|
+
column_rename: dict[str, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pydantic_field_names(model: type) -> list[tuple[str, list[str], str | None, list[str]]]:
|
|
22
|
+
try:
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
except ImportError as exc:
|
|
25
|
+
raise ImportError("Install tejtm with pydantic extra: pip install tejtm[pydantic]") from exc
|
|
26
|
+
if not isinstance(model, type) or not issubclass(model, BaseModel):
|
|
27
|
+
raise TypeError("model must be a Pydantic BaseModel subclass")
|
|
28
|
+
|
|
29
|
+
out: list[tuple[str, list[str], str | None, list[str]]] = []
|
|
30
|
+
for name, field_info in model.model_fields.items():
|
|
31
|
+
aliases: list[str] = []
|
|
32
|
+
extra = field_info.json_schema_extra
|
|
33
|
+
if isinstance(extra, dict):
|
|
34
|
+
raw = extra.get("aliases") or extra.get("tejtm_aliases")
|
|
35
|
+
if isinstance(raw, str):
|
|
36
|
+
aliases.append(raw)
|
|
37
|
+
elif isinstance(raw, (list, tuple)):
|
|
38
|
+
aliases.extend(str(a) for a in raw)
|
|
39
|
+
title = field_info.title or name
|
|
40
|
+
expected = None
|
|
41
|
+
exemplars: list[str] = []
|
|
42
|
+
if isinstance(extra, dict):
|
|
43
|
+
if extra.get("semantic_type"):
|
|
44
|
+
expected = str(extra["semantic_type"])
|
|
45
|
+
raw_ex = extra.get("tejtm_exemplars") or extra.get("exemplar_samples")
|
|
46
|
+
if isinstance(raw_ex, str):
|
|
47
|
+
exemplars.append(raw_ex)
|
|
48
|
+
elif isinstance(raw_ex, (list, tuple)):
|
|
49
|
+
exemplars.extend(str(x) for x in raw_ex)
|
|
50
|
+
out.append((name, [title, *aliases], expected, exemplars))
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def target_schema_from_model(model: type) -> TargetSchema:
|
|
55
|
+
fields = [
|
|
56
|
+
TargetField(
|
|
57
|
+
id=name,
|
|
58
|
+
labels=(labels[0],) if labels else (),
|
|
59
|
+
aliases=tuple(labels[1:]),
|
|
60
|
+
expected_semantic_type=expected,
|
|
61
|
+
exemplar_samples=tuple(exemplars),
|
|
62
|
+
)
|
|
63
|
+
for name, labels, expected, exemplars in _pydantic_field_names(model)
|
|
64
|
+
]
|
|
65
|
+
return TargetSchema(fields=fields)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def propose_and_apply(
|
|
69
|
+
model: type[T],
|
|
70
|
+
rows: Sequence[Mapping[str, Any]],
|
|
71
|
+
*,
|
|
72
|
+
config: MappingConfig | None = None,
|
|
73
|
+
engine: MappingEngine | None = None,
|
|
74
|
+
) -> ImportResult:
|
|
75
|
+
if not rows:
|
|
76
|
+
return ImportResult(valid_rows=[], errors=[], mapping=MappingResult((), (), ()), column_rename={})
|
|
77
|
+
|
|
78
|
+
columns = list(rows[0].keys())
|
|
79
|
+
samples_by_col: dict[str, list[str]] = {c: [] for c in columns}
|
|
80
|
+
for row in rows[:50]:
|
|
81
|
+
for c in columns:
|
|
82
|
+
val = row.get(c)
|
|
83
|
+
if val is not None and str(val).strip():
|
|
84
|
+
samples_by_col[c].append(str(val))
|
|
85
|
+
|
|
86
|
+
source = SourceProfile(
|
|
87
|
+
columns=[SourceColumn(name=c, samples=tuple(samples_by_col[c][:20])) for c in columns]
|
|
88
|
+
)
|
|
89
|
+
target = target_schema_from_model(model)
|
|
90
|
+
eng = engine or MappingEngine()
|
|
91
|
+
mapping = eng.propose_mappings(source, target, config=config)
|
|
92
|
+
rename = {m.source_column: m.target_field for m in mapping.mappings}
|
|
93
|
+
|
|
94
|
+
valid: list[Any] = []
|
|
95
|
+
errors: list[dict[str, Any]] = []
|
|
96
|
+
field_types = {f.id: f.expected_semantic_type or "freetext" for f in target.fields if f.expected_semantic_type}
|
|
97
|
+
|
|
98
|
+
for idx, row in enumerate(rows):
|
|
99
|
+
renamed = {rename.get(k, k): v for k, v in row.items()}
|
|
100
|
+
coerced = coerce_record(renamed, field_types)
|
|
101
|
+
try:
|
|
102
|
+
valid.append(model.model_validate(coerced))
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
errors.append({"row": idx + 1, "message": str(exc)})
|
|
105
|
+
|
|
106
|
+
return ImportResult(valid_rows=valid, errors=errors, mapping=mapping, column_rename=rename)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from tejtm.scorers.exact import ExactNameScorer
|
|
2
|
+
from tejtm.scorers.fuzzy import FuzzyNameScorer
|
|
3
|
+
from tejtm.scorers.header_embedding import HeaderEmbeddingScorer
|
|
4
|
+
from tejtm.scorers.semantic_type import SemanticTypeScorer
|
|
5
|
+
from tejtm.scorers.value_intent import ValueIntentScorer
|
|
6
|
+
|
|
7
|
+
DEFAULT_SCORERS = (
|
|
8
|
+
ExactNameScorer(),
|
|
9
|
+
FuzzyNameScorer(),
|
|
10
|
+
SemanticTypeScorer(),
|
|
11
|
+
HeaderEmbeddingScorer(),
|
|
12
|
+
ValueIntentScorer(),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_SCORERS",
|
|
17
|
+
"ExactNameScorer",
|
|
18
|
+
"FuzzyNameScorer",
|
|
19
|
+
"HeaderEmbeddingScorer",
|
|
20
|
+
"SemanticTypeScorer",
|
|
21
|
+
"ValueIntentScorer",
|
|
22
|
+
]
|
tejtm/scorers/base.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from tejtm.models import MappingConfig, ScorerResult, SourceColumn, TargetField, TypeGuess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ScoringContext:
|
|
11
|
+
config: MappingConfig
|
|
12
|
+
column_types: dict[str, TypeGuess] = field(default_factory=dict)
|
|
13
|
+
field_embeddings: dict[str, tuple[float, ...]] | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Scorer(Protocol):
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
def score(
|
|
20
|
+
self,
|
|
21
|
+
column: SourceColumn,
|
|
22
|
+
field: TargetField,
|
|
23
|
+
context: ScoringContext,
|
|
24
|
+
) -> ScorerResult | None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def aggregate_scores(parts: list[ScorerResult]) -> float:
|
|
28
|
+
if not parts:
|
|
29
|
+
return 0.0
|
|
30
|
+
num = sum(p.score * p.weight for p in parts)
|
|
31
|
+
den = sum(p.weight for p in parts)
|
|
32
|
+
if den <= 0:
|
|
33
|
+
return 0.0
|
|
34
|
+
return max(0.0, min(1.0, num / den))
|
tejtm/scorers/exact.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
|
|
4
|
+
from tejtm.normalize import candidate_names, normalize_identifier
|
|
5
|
+
from tejtm.scorers.base import ScoringContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExactNameScorer:
|
|
9
|
+
name = "exact_name"
|
|
10
|
+
|
|
11
|
+
def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
|
|
12
|
+
col = normalize_identifier(column.name)
|
|
13
|
+
if not col:
|
|
14
|
+
return None
|
|
15
|
+
names = candidate_names(field.id, field.labels, field.aliases)
|
|
16
|
+
if col in names:
|
|
17
|
+
w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
|
|
18
|
+
return ScorerResult(scorer=self.name, score=1.0, weight=w, reason="exact normalized match")
|
|
19
|
+
return None
|
tejtm/scorers/fuzzy.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rapidfuzz import fuzz
|
|
4
|
+
|
|
5
|
+
from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
|
|
6
|
+
from tejtm.normalize import candidate_names, normalize_identifier, tokenize_identifier
|
|
7
|
+
from tejtm.scorers.base import ScoringContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FuzzyNameScorer:
|
|
11
|
+
name = "fuzzy_name"
|
|
12
|
+
|
|
13
|
+
def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
|
|
14
|
+
col_norm = normalize_identifier(column.name)
|
|
15
|
+
col_tokens = " ".join(tokenize_identifier(column.name))
|
|
16
|
+
if not col_norm:
|
|
17
|
+
return None
|
|
18
|
+
best = 0.0
|
|
19
|
+
for cand in candidate_names(field.id, field.labels, field.aliases):
|
|
20
|
+
scores = [
|
|
21
|
+
fuzz.ratio(col_norm, cand) / 100.0,
|
|
22
|
+
fuzz.WRatio(col_norm, cand) / 100.0,
|
|
23
|
+
fuzz.token_sort_ratio(col_tokens or col_norm, cand) / 100.0,
|
|
24
|
+
fuzz.token_set_ratio(col_tokens or col_norm, cand) / 100.0,
|
|
25
|
+
]
|
|
26
|
+
best = max(best, max(scores))
|
|
27
|
+
w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
|
|
28
|
+
return ScorerResult(scorer=self.name, score=best, weight=w, reason=f"fuzzy best={best:.2f}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
|
|
6
|
+
from tejtm.normalize import candidate_names
|
|
7
|
+
from tejtm.scorers.base import ScoringContext
|
|
8
|
+
|
|
9
|
+
_embedder = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_embedder():
|
|
13
|
+
global _embedder
|
|
14
|
+
if _embedder is False:
|
|
15
|
+
return None
|
|
16
|
+
if _embedder is not None and _embedder is not False:
|
|
17
|
+
return _embedder
|
|
18
|
+
try:
|
|
19
|
+
from fastembed import TextEmbedding
|
|
20
|
+
|
|
21
|
+
_embedder = TextEmbedding(
|
|
22
|
+
model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
|
23
|
+
)
|
|
24
|
+
except ImportError:
|
|
25
|
+
_embedder = False
|
|
26
|
+
return _embedder if _embedder is not False else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cosine(a: tuple[float, ...], b: tuple[float, ...]) -> float:
|
|
30
|
+
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
|
31
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
32
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
33
|
+
if na == 0 or nb == 0:
|
|
34
|
+
return 0.0
|
|
35
|
+
return max(0.0, min(1.0, dot / (na * nb)))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HeaderEmbeddingScorer:
|
|
39
|
+
name = "header_embedding"
|
|
40
|
+
|
|
41
|
+
def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
|
|
42
|
+
model = _get_embedder()
|
|
43
|
+
if model is None:
|
|
44
|
+
return None
|
|
45
|
+
if context.field_embeddings is None:
|
|
46
|
+
return None
|
|
47
|
+
col_vec = _embed_text(model, column.name)
|
|
48
|
+
if col_vec is None:
|
|
49
|
+
return None
|
|
50
|
+
best = 0.0
|
|
51
|
+
for cand in candidate_names(field.id, field.labels, field.aliases):
|
|
52
|
+
fvec = context.field_embeddings.get(cand)
|
|
53
|
+
if fvec is None:
|
|
54
|
+
fvec = _embed_text(model, cand)
|
|
55
|
+
if fvec:
|
|
56
|
+
context.field_embeddings[cand] = fvec
|
|
57
|
+
if fvec:
|
|
58
|
+
best = max(best, _cosine(col_vec, fvec))
|
|
59
|
+
w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
|
|
60
|
+
return ScorerResult(scorer=self.name, score=best, weight=w, reason=f"embedding cosine={best:.2f}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _embed_text(model, text: str) -> tuple[float, ...] | None:
|
|
64
|
+
if not text.strip():
|
|
65
|
+
return None
|
|
66
|
+
vectors = list(model.embed([text]))
|
|
67
|
+
if not vectors:
|
|
68
|
+
return None
|
|
69
|
+
return tuple(float(x) for x in vectors[0])
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
|
|
4
|
+
from tejtm.scorers.base import ScoringContext
|
|
5
|
+
from tejtm.typing.detectors import type_compatibility
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SemanticTypeScorer:
|
|
9
|
+
name = "semantic_type"
|
|
10
|
+
|
|
11
|
+
def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
|
|
12
|
+
if field.expected_semantic_type is None:
|
|
13
|
+
return None
|
|
14
|
+
guess = context.column_types.get(column.name)
|
|
15
|
+
if guess is None:
|
|
16
|
+
return None
|
|
17
|
+
score = type_compatibility(guess, field.expected_semantic_type)
|
|
18
|
+
if score <= 0:
|
|
19
|
+
return ScorerResult(
|
|
20
|
+
scorer=self.name,
|
|
21
|
+
score=0.0,
|
|
22
|
+
weight=context.config.weight(self.name, DEFAULT_WEIGHTS[self.name]),
|
|
23
|
+
reason=f"source={guess.label} expected={field.expected_semantic_type}",
|
|
24
|
+
)
|
|
25
|
+
w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
|
|
26
|
+
return ScorerResult(
|
|
27
|
+
scorer=self.name,
|
|
28
|
+
score=score,
|
|
29
|
+
weight=w,
|
|
30
|
+
reason=f"source={guess.label} expected={field.expected_semantic_type}",
|
|
31
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
|
|
4
|
+
from tejtm.scorers.base import ScoringContext
|
|
5
|
+
from tejtm.value_profile import expectation_from_type, signature_similarity, value_signature
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValueIntentScorer:
|
|
9
|
+
name = "value_intent"
|
|
10
|
+
|
|
11
|
+
def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
|
|
12
|
+
column_sig = value_signature(column.samples)
|
|
13
|
+
if column_sig is None:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
scores: list[tuple[float, str]] = []
|
|
17
|
+
|
|
18
|
+
if field.expected_semantic_type:
|
|
19
|
+
expected_sig = expectation_from_type(field.expected_semantic_type)
|
|
20
|
+
sim = signature_similarity(column_sig, expected_sig)
|
|
21
|
+
scores.append((sim, f"type_profile:{field.expected_semantic_type}:{sim:.2f}"))
|
|
22
|
+
|
|
23
|
+
if field.exemplar_samples:
|
|
24
|
+
exemplar_sig = value_signature(field.exemplar_samples)
|
|
25
|
+
if exemplar_sig is not None:
|
|
26
|
+
sim = signature_similarity(column_sig, exemplar_sig)
|
|
27
|
+
scores.append((sim, f"exemplar:{sim:.2f}"))
|
|
28
|
+
|
|
29
|
+
embed_score = _description_value_embedding_score(column, field, context)
|
|
30
|
+
if embed_score is not None:
|
|
31
|
+
scores.append((embed_score, f"desc_embed:{embed_score:.2f}"))
|
|
32
|
+
|
|
33
|
+
if not scores:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
best_score, reason = max(scores, key=lambda t: t[0])
|
|
37
|
+
if best_score <= 0:
|
|
38
|
+
return None
|
|
39
|
+
w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
|
|
40
|
+
return ScorerResult(scorer=self.name, score=best_score, weight=w, reason=reason)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _description_value_embedding_score(
|
|
44
|
+
column: SourceColumn,
|
|
45
|
+
field: TargetField,
|
|
46
|
+
context: ScoringContext,
|
|
47
|
+
) -> float | None:
|
|
48
|
+
if not field.description:
|
|
49
|
+
return None
|
|
50
|
+
if context.field_embeddings is None:
|
|
51
|
+
return None
|
|
52
|
+
from tejtm.scorers.header_embedding import _cosine, _embed_text, _get_embedder
|
|
53
|
+
|
|
54
|
+
model = _get_embedder()
|
|
55
|
+
if model is None:
|
|
56
|
+
return None
|
|
57
|
+
samples = [s for s in column.samples if s and str(s).strip()][:5]
|
|
58
|
+
if not samples:
|
|
59
|
+
return None
|
|
60
|
+
col_text = " | ".join(samples)
|
|
61
|
+
field_text = f"{field.id} {field.description}"
|
|
62
|
+
col_vec = _embed_text(model, col_text)
|
|
63
|
+
field_vec = context.field_embeddings.get(f"desc:{field.id}")
|
|
64
|
+
if field_vec is None:
|
|
65
|
+
field_vec = _embed_text(model, field_text)
|
|
66
|
+
if field_vec:
|
|
67
|
+
context.field_embeddings[f"desc:{field.id}"] = field_vec
|
|
68
|
+
if col_vec is None or field_vec is None:
|
|
69
|
+
return None
|
|
70
|
+
return _cosine(col_vec, field_vec)
|
tejtm/serialization.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tejtm.models import MappingResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def mapping_to_yaml(result: MappingResult) -> str:
|
|
9
|
+
try:
|
|
10
|
+
import yaml
|
|
11
|
+
except ImportError as exc:
|
|
12
|
+
raise ImportError("Install tejtm with yaml extra: pip install tejtm[yaml]") from exc
|
|
13
|
+
return yaml.safe_dump(result.to_dict(), sort_keys=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def mapping_from_yaml(text: str) -> MappingResult:
|
|
17
|
+
try:
|
|
18
|
+
import yaml
|
|
19
|
+
except ImportError as exc:
|
|
20
|
+
raise ImportError("Install tejtm with yaml extra: pip install tejtm[yaml]") from exc
|
|
21
|
+
data: Any = yaml.safe_load(text)
|
|
22
|
+
return MappingResult.from_dict(data or {})
|
tejtm/typing/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Callable, Sequence
|
|
6
|
+
|
|
7
|
+
from tejtm.models import TypeGuess
|
|
8
|
+
|
|
9
|
+
_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", re.IGNORECASE)
|
|
10
|
+
_URL = re.compile(r"^https?://\S+$", re.IGNORECASE)
|
|
11
|
+
_UUID = re.compile(
|
|
12
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
_PHONE = re.compile(r"^\+?[\d\s().-]{7,}$")
|
|
16
|
+
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
17
|
+
_DATETIME = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}")
|
|
18
|
+
_TIME = re.compile(r"^\d{1,2}:\d{2}(:\d{2})?$")
|
|
19
|
+
_CURRENCY = re.compile(
|
|
20
|
+
r"^[\$€£]\s*-?\d{1,3}(?:,\d{3})*(?:\.\d+)?$|"
|
|
21
|
+
r"^-?\d{1,3}(?:,\d{3})+(?:\.\d+)?$"
|
|
22
|
+
)
|
|
23
|
+
_BOOL = {"true", "false", "yes", "no", "0", "1", "y", "n"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _match_rate(samples: Sequence[str], predicate: Callable[[str], bool]) -> tuple[float, str]:
|
|
27
|
+
values = [s.strip() for s in samples if s and str(s).strip()]
|
|
28
|
+
if not values:
|
|
29
|
+
return 0.0, "no samples"
|
|
30
|
+
hits = sum(1 for v in values if predicate(v))
|
|
31
|
+
rate = hits / len(values)
|
|
32
|
+
return rate, f"{hits}/{len(values)} matched"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_semantic_type(samples: Sequence[str]) -> TypeGuess:
|
|
36
|
+
checks: list[tuple[str, Callable[[str], bool]]] = [
|
|
37
|
+
("email", lambda v: bool(_EMAIL.match(v))),
|
|
38
|
+
("url", lambda v: bool(_URL.match(v))),
|
|
39
|
+
("uuid", lambda v: bool(_UUID.match(v))),
|
|
40
|
+
("phone", _is_phone),
|
|
41
|
+
("datetime", lambda v: bool(_DATETIME.match(v))),
|
|
42
|
+
("date", lambda v: bool(_DATE.match(v)) and _parse_date(v)),
|
|
43
|
+
("time", lambda v: bool(_TIME.match(v))),
|
|
44
|
+
("boolean", lambda v: v.casefold() in _BOOL),
|
|
45
|
+
("currency", lambda v: bool(_CURRENCY.match(v.replace(" ", "")))),
|
|
46
|
+
("integer", _is_int),
|
|
47
|
+
("number", _is_number),
|
|
48
|
+
]
|
|
49
|
+
best_label = "freetext"
|
|
50
|
+
best_rate = 0.0
|
|
51
|
+
best_evidence = ""
|
|
52
|
+
for label, pred in checks:
|
|
53
|
+
rate, evidence = _match_rate(samples, pred)
|
|
54
|
+
if rate > best_rate:
|
|
55
|
+
best_rate = rate
|
|
56
|
+
best_label = label
|
|
57
|
+
best_evidence = evidence
|
|
58
|
+
if best_rate < 0.5:
|
|
59
|
+
return TypeGuess(label="freetext", confidence=best_rate, evidence=best_evidence or "weak signal")
|
|
60
|
+
return TypeGuess(label=best_label, confidence=best_rate, evidence=best_evidence)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_phone(value: str) -> bool:
|
|
64
|
+
if _DATE.match(value) or _DATETIME.match(value) or _TIME.match(value):
|
|
65
|
+
return False
|
|
66
|
+
if not _PHONE.match(value):
|
|
67
|
+
return False
|
|
68
|
+
digits = sum(c.isdigit() for c in value)
|
|
69
|
+
if digits < 7:
|
|
70
|
+
return False
|
|
71
|
+
return not value.isdigit()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_int(value: str) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
int(value.replace(",", ""))
|
|
77
|
+
return "." not in value
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_number(value: str) -> bool:
|
|
83
|
+
try:
|
|
84
|
+
float(value.replace(",", "").replace("$", "").replace("€", "").replace("£", ""))
|
|
85
|
+
return True
|
|
86
|
+
except ValueError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_date(value: str) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
datetime.strptime(value, "%Y-%m-%d")
|
|
93
|
+
return True
|
|
94
|
+
except ValueError:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def type_compatibility(source: TypeGuess, expected: str | None) -> float:
|
|
99
|
+
if expected is None:
|
|
100
|
+
return 0.0
|
|
101
|
+
if source.label == expected:
|
|
102
|
+
return min(1.0, 0.5 + 0.5 * source.confidence)
|
|
103
|
+
compatible = {
|
|
104
|
+
("integer", "number"),
|
|
105
|
+
("number", "integer"),
|
|
106
|
+
("datetime", "date"),
|
|
107
|
+
("date", "datetime"),
|
|
108
|
+
("number", "currency"),
|
|
109
|
+
("currency", "number"),
|
|
110
|
+
}
|
|
111
|
+
if (source.label, expected) in compatible:
|
|
112
|
+
return 0.6 * source.confidence
|
|
113
|
+
return 0.0
|
tejtm/value_profile.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from tejtm.models import TypeGuess
|
|
8
|
+
from tejtm.typing.detectors import detect_semantic_type, type_compatibility
|
|
9
|
+
|
|
10
|
+
_WS = re.compile(r"\s")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ValueSignature:
|
|
15
|
+
semantic_type: str
|
|
16
|
+
type_confidence: float
|
|
17
|
+
avg_length: float
|
|
18
|
+
digit_ratio: float
|
|
19
|
+
space_ratio: float
|
|
20
|
+
distinct_ratio: float
|
|
21
|
+
uppercase_ratio: float
|
|
22
|
+
token_prefix: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def value_signature(samples: Sequence[str]) -> ValueSignature | None:
|
|
26
|
+
values = [str(s).strip() for s in samples if s is not None and str(s).strip()]
|
|
27
|
+
if not values:
|
|
28
|
+
return None
|
|
29
|
+
guess = detect_semantic_type(values)
|
|
30
|
+
total_chars = sum(len(v) for v in values)
|
|
31
|
+
digits = sum(sum(c.isdigit() for c in v) for v in values)
|
|
32
|
+
spaces = sum(len(_WS.findall(v)) for v in values)
|
|
33
|
+
distinct = len(set(values)) / len(values)
|
|
34
|
+
upper = sum(1 for v in values if v.isupper()) / len(values)
|
|
35
|
+
return ValueSignature(
|
|
36
|
+
semantic_type=guess.label,
|
|
37
|
+
type_confidence=guess.confidence,
|
|
38
|
+
avg_length=total_chars / len(values),
|
|
39
|
+
digit_ratio=digits / total_chars if total_chars else 0.0,
|
|
40
|
+
space_ratio=spaces / total_chars if total_chars else 0.0,
|
|
41
|
+
distinct_ratio=distinct,
|
|
42
|
+
uppercase_ratio=upper,
|
|
43
|
+
token_prefix=_dominant_prefix(values),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def signature_similarity(a: ValueSignature, b: ValueSignature) -> float:
|
|
48
|
+
type_score = type_compatibility(
|
|
49
|
+
TypeGuess(a.semantic_type, a.type_confidence),
|
|
50
|
+
b.semantic_type,
|
|
51
|
+
)
|
|
52
|
+
if type_score <= 0 and a.semantic_type != b.semantic_type:
|
|
53
|
+
type_part = 0.15 if _types_loosely_compatible(a.semantic_type, b.semantic_type) else 0.0
|
|
54
|
+
else:
|
|
55
|
+
type_part = type_score
|
|
56
|
+
|
|
57
|
+
prefix_part = 1.0 if a.token_prefix and a.token_prefix == b.token_prefix else 0.0
|
|
58
|
+
shape = 1.0 - min(
|
|
59
|
+
1.0,
|
|
60
|
+
(
|
|
61
|
+
abs(a.digit_ratio - b.digit_ratio)
|
|
62
|
+
+ abs(a.space_ratio - b.space_ratio)
|
|
63
|
+
+ abs(a.distinct_ratio - b.distinct_ratio) * 0.5
|
|
64
|
+
+ abs(a.uppercase_ratio - b.uppercase_ratio) * 0.5
|
|
65
|
+
+ min(1.0, abs(a.avg_length - b.avg_length) / max(a.avg_length, b.avg_length, 1.0))
|
|
66
|
+
)
|
|
67
|
+
/ 3.0,
|
|
68
|
+
)
|
|
69
|
+
blended = 0.45 * type_part + 0.35 * shape + 0.20 * prefix_part
|
|
70
|
+
return max(0.0, min(1.0, blended))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def expectation_from_type(expected_semantic_type: str) -> ValueSignature:
|
|
74
|
+
profiles: dict[str, ValueSignature] = {
|
|
75
|
+
"email": ValueSignature("email", 1.0, 22.0, 0.0, 0.0, 0.9, 0.0),
|
|
76
|
+
"phone": ValueSignature("phone", 1.0, 12.0, 0.5, 0.05, 0.9, 0.0),
|
|
77
|
+
"uuid": ValueSignature("uuid", 1.0, 36.0, 0.0, 0.0, 1.0, 0.0),
|
|
78
|
+
"url": ValueSignature("url", 1.0, 30.0, 0.05, 0.0, 0.9, 0.0),
|
|
79
|
+
"date": ValueSignature("date", 1.0, 10.0, 0.3, 0.0, 0.8, 0.0),
|
|
80
|
+
"datetime": ValueSignature("datetime", 1.0, 19.0, 0.35, 0.05, 0.8, 0.0),
|
|
81
|
+
"integer": ValueSignature("integer", 1.0, 4.0, 0.9, 0.0, 0.7, 0.0),
|
|
82
|
+
"number": ValueSignature("number", 1.0, 6.0, 0.4, 0.0, 0.7, 0.0),
|
|
83
|
+
"currency": ValueSignature("currency", 1.0, 8.0, 0.5, 0.0, 0.6, 0.0),
|
|
84
|
+
"boolean": ValueSignature("boolean", 1.0, 4.0, 0.0, 0.0, 0.3, 0.0),
|
|
85
|
+
"freetext": ValueSignature("freetext", 0.5, 16.0, 0.1, 0.12, 0.85, 0.1),
|
|
86
|
+
}
|
|
87
|
+
return profiles.get(expected_semantic_type, profiles["freetext"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _dominant_prefix(values: Sequence[str]) -> str:
|
|
91
|
+
buckets: dict[str, int] = {}
|
|
92
|
+
for v in values:
|
|
93
|
+
token = v.strip()
|
|
94
|
+
if "-" in token:
|
|
95
|
+
key = token.split("-", 1)[0].casefold()
|
|
96
|
+
elif len(token) >= 3:
|
|
97
|
+
key = token[:3].casefold()
|
|
98
|
+
else:
|
|
99
|
+
key = token.casefold()
|
|
100
|
+
buckets[key] = buckets.get(key, 0) + 1
|
|
101
|
+
if not buckets:
|
|
102
|
+
return ""
|
|
103
|
+
return max(buckets, key=buckets.get)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _types_loosely_compatible(a: str, b: str) -> bool:
|
|
107
|
+
pairs = {("integer", "number"), ("date", "datetime"), ("freetext", "uuid")}
|
|
108
|
+
return (a, b) in pairs or (b, a) in pairs
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tejtm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Schema mapping with composite confidence for tabular imports
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: rapidfuzz>=3.0
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: email-validator>=2.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: openpyxl>=3.1; extra == 'dev'
|
|
10
|
+
Requires-Dist: pydantic>=2.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Provides-Extra: phone
|
|
13
|
+
Requires-Dist: phonenumbers>=8.13; extra == 'phone'
|
|
14
|
+
Provides-Extra: pydantic
|
|
15
|
+
Requires-Dist: email-validator>=2.0; extra == 'pydantic'
|
|
16
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
17
|
+
Provides-Extra: semantic
|
|
18
|
+
Requires-Dist: fastembed>=0.4; extra == 'semantic'
|
|
19
|
+
Provides-Extra: yaml
|
|
20
|
+
Requires-Dist: pyyaml>=6; extra == 'yaml'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# tejtm
|
|
24
|
+
|
|
25
|
+
tejtm maps messy import columns to a target schema you define. It returns one-to-one pairs with a composite confidence and a per-scorer breakdown. It does not read files, join records, or validate rows.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e ".[dev]"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Optional extras:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e ".[semantic,pydantic,yaml]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`semantic` adds FastEmbed header similarity. `pydantic` builds a target schema from a model. `yaml` saves mapping results.
|
|
40
|
+
|
|
41
|
+
## Map columns to a target schema
|
|
42
|
+
|
|
43
|
+
The library takes column names plus sample values. You keep file I/O in your app.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from tejtm import MappingEngine, MappingConfig, SourceColumn, SourceProfile, TargetField, TargetSchema
|
|
47
|
+
|
|
48
|
+
source = SourceProfile(columns=[
|
|
49
|
+
SourceColumn("phone", samples=("+1 555-0100",)),
|
|
50
|
+
SourceColumn("contact", samples=("user@example.com",)),
|
|
51
|
+
SourceColumn("Cust Name", samples=("Ada Lovelace",)),
|
|
52
|
+
])
|
|
53
|
+
target = TargetSchema(fields=[
|
|
54
|
+
TargetField("phone_number", aliases=("phone",), expected_semantic_type="phone"),
|
|
55
|
+
TargetField("email", expected_semantic_type="email"),
|
|
56
|
+
TargetField("full_name", aliases=("name", "customer name")),
|
|
57
|
+
])
|
|
58
|
+
result = MappingEngine().propose_mappings(
|
|
59
|
+
source,
|
|
60
|
+
target,
|
|
61
|
+
config=MappingConfig(min_confidence=0.5),
|
|
62
|
+
)
|
|
63
|
+
for mapping in result.mappings:
|
|
64
|
+
print(mapping.source_column, "->", mapping.target_field, mapping.confidence)
|
|
65
|
+
print("unmapped", result.unmapped_columns)
|
|
66
|
+
print("unmatched", result.unmatched_fields)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You should see `phone -> phone_number`, `contact -> email`, and `Cust Name -> full_name`. Weak pairs stay out of `mappings` and land in `unmapped_columns` or `unmatched_fields`.
|
|
70
|
+
|
|
71
|
+
Default `min_confidence` is `0.55`. Lower it for messy vendor headers, then auto-apply only the pairs above a threshold you pick.
|
|
72
|
+
|
|
73
|
+
## Apply the mapping
|
|
74
|
+
|
|
75
|
+
tejtm does not rewrite rows. Use `source_column` and `target_field` to rename keys in your own loop. For cell casting after rename, call `coerce_value` or `coerce_record`. For a Pydantic model, see [How to customize mapping](docs/customization.md).
|
|
76
|
+
|
|
77
|
+
## Fixture results
|
|
78
|
+
|
|
79
|
+
The six directories under `tests/fixtures/scenarios/` are the only sector samples in this repo. They are small synthetic tables, not a customer corpus. Treat a fixture as passing when every `expect_mappings` pair lands in `result.mappings` at that fixture's `min_confidence`.
|
|
80
|
+
|
|
81
|
+
Measured 29 Oct 2026 in this checkout, FastEmbed absent, two identical runs. `header_embedding` abstained. Scores come from `exact_name`, `fuzzy_name`, `semantic_type`, and `value_intent` only. The limiter is fixture size (2 or 3 columns) and those lowered thresholds, not production files.
|
|
82
|
+
|
|
83
|
+
| Fixture | Format | min_confidence | Mapped / expected | Lowest mapped pair |
|
|
84
|
+
| --- | --- | --- | --- | --- |
|
|
85
|
+
| `crm_contacts` | csv | 0.45 | 3 / 3 | `contact` -> `email` (0.549) |
|
|
86
|
+
| `edge_duplicate_generic_headers` | csv | 0.35 | 2 / 2 | `ref_a` -> `order_reference` (0.601) |
|
|
87
|
+
| `finance_ledger` | xlsx | 0.4 | 3 / 3 | `GL Code` -> `ledger_account` (0.752) |
|
|
88
|
+
| `healthcare_appointments` | csv | 0.4 | 3 / 3 | `Clinic` -> `facility_name` (0.480) |
|
|
89
|
+
| `hr_payroll` | csv | 0.4 | 3 / 3 | `Gross Pay` -> `gross_amount` (0.569) |
|
|
90
|
+
| `warehouse_inventory` | csv | 0.4 | 3 / 3 | `Bin Loc` -> `storage_location` (0.807) |
|
|
91
|
+
|
|
92
|
+
All 17 expected pairs mapped at those fixture thresholds. At the library default of `0.55`, 15 of 17 still map. The two that drop are `contact` -> `email` in `crm_contacts` (0.549) and `Clinic` -> `facility_name` in `healthcare_appointments` (0.480). See [About mapping limits](docs/limitations.md) for why demos sit below the default.
|
|
93
|
+
|
|
94
|
+
`.venv/bin/python -m pytest -q` reported 52 passed in this checkout. That count is the test suite, not a mapping-accuracy figure.
|
|
95
|
+
|
|
96
|
+
Regenerate the table:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
.venv/bin/python tests/support/fixture_stats.py
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## What to read next
|
|
103
|
+
|
|
104
|
+
- [How to customize mapping](docs/customization.md) covers weights, overrides, custom scorers, exemplars, Pydantic, and YAML.
|
|
105
|
+
- [About mapping limits](docs/limitations.md) covers joins, duplicate headers, optional extras, and unused config.
|
|
106
|
+
- [Product spec](docs/specs/schema-mapping-spec.md) is the v1 contract.
|
|
107
|
+
- [Glossary](docs/GLOSSARY.md) defines source column, target field, and value intent.
|
|
108
|
+
|
|
109
|
+
Run tests with the project virtualenv:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
.venv/bin/python -m pytest -q
|
|
113
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
tejtm/__init__.py,sha256=VAnT4aWGRuKJd0zL3Ghx3rCoSsaFDCuCafcn4r0RLTM,584
|
|
2
|
+
tejtm/assignment.py,sha256=kKLhc_R30G6XbUJ9BpHUzT0s9MUP083vb595-KUz-JU,2063
|
|
3
|
+
tejtm/coercion.py,sha256=1w2UMNMWizkxD3aFyIgni-CGuHVY60ZGkaJcL_J3B18,1632
|
|
4
|
+
tejtm/engine.py,sha256=ARrqP056Fp11eFPP2e9SF4dPPO_rVCUZ9fy0vh0E_IM,4321
|
|
5
|
+
tejtm/models.py,sha256=rIehZReUv6qIlzVFUOFJ-lvtAmJrjrWr01jgeKhfjTc,3892
|
|
6
|
+
tejtm/normalize.py,sha256=ze54_b9_2NYixbVTvDZVckHupooMUonPWojMsaVoGmo,1315
|
|
7
|
+
tejtm/pydantic_adapter.py,sha256=jYb0itc51lsPjkYYmx80HbJjG1bT6faMG0xqYuzxLLs,3982
|
|
8
|
+
tejtm/serialization.py,sha256=PVZ_BgA0qOiCESbBa9-yccZYfEiIr7Ok97UctcX458Y,656
|
|
9
|
+
tejtm/value_profile.py,sha256=2tr8lTPtLW-lyknDtHuL7rE7M9TofCT8GjP6yVoWjM4,4053
|
|
10
|
+
tejtm/scorers/__init__.py,sha256=3cnOd2QnmqJtoa0JMLNOhWnx891TkXli4EP-Y_ARx1I,590
|
|
11
|
+
tejtm/scorers/base.py,sha256=R93JjO45TjGLtMqdG9-gVBrjPBNHEx_xiskDeYkUJ1o,849
|
|
12
|
+
tejtm/scorers/exact.py,sha256=-NesDzCycBX5gYbbWJYqJTv7fmbR0SbuV8NppIon9Mc,780
|
|
13
|
+
tejtm/scorers/fuzzy.py,sha256=It7H_ME19WKDd_lX5JY0MRvy-PSiWaFff_n58a4Ztao,1212
|
|
14
|
+
tejtm/scorers/header_embedding.py,sha256=FuhSZUMrOo20q7yIrB1_oLV-1jk4tYe7b13adZcvkaU,2234
|
|
15
|
+
tejtm/scorers/semantic_type.py,sha256=A_o6lw6WoMhuuIksRXx5Lln9mO5IA851w2vRpp6JkJ4,1211
|
|
16
|
+
tejtm/scorers/value_intent.py,sha256=wld6P2n2KSpVs5aoFDxEZ7RKgmK5AfK202LiT3kwSuc,2623
|
|
17
|
+
tejtm/typing/__init__.py,sha256=lp8D-9bz25HKpp3i13dMdBFr-CB11L8Bzr81C7vd4Qk,134
|
|
18
|
+
tejtm/typing/detectors.py,sha256=mgBj6HHHMC_iijnivtZH0YIgJbIT1M3jrGI-qJ1gIlM,3619
|
|
19
|
+
tejtm-0.1.0.dist-info/METADATA,sha256=xVoAyLiNfZz2IVhEiV3k9332tLEShEyd0LPCiEMX_Eo,5067
|
|
20
|
+
tejtm-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
21
|
+
tejtm-0.1.0.dist-info/RECORD,,
|