bibraeval 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.
- bibraeval/__init__.py +22 -0
- bibraeval/aggregator.py +218 -0
- bibraeval/comparator.py +178 -0
- bibraeval/data_ingester.py +132 -0
- bibraeval/evaluator.py +279 -0
- bibraeval/field_metrics.py +280 -0
- bibraeval/metric_conf.py +83 -0
- bibraeval-0.1.0.dist-info/METADATA +114 -0
- bibraeval-0.1.0.dist-info/RECORD +12 -0
- bibraeval-0.1.0.dist-info/WHEEL +5 -0
- bibraeval-0.1.0.dist-info/licenses/LICENSE +287 -0
- bibraeval-0.1.0.dist-info/top_level.txt +1 -0
bibraeval/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""BIBRA evaluation package."""
|
|
2
|
+
|
|
3
|
+
from .aggregator import Aggregator, IntermediateResults
|
|
4
|
+
from .comparator import Comparator
|
|
5
|
+
from .data_ingester import Record, RecordCollection
|
|
6
|
+
from .evaluator import Evaluator
|
|
7
|
+
from .field_metrics import Exact, FieldMetric, Levenshtein
|
|
8
|
+
from .metric_conf import FieldMetricConfig, FieldMetricMapping
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Aggregator",
|
|
12
|
+
"Comparator",
|
|
13
|
+
"Evaluator",
|
|
14
|
+
"Exact",
|
|
15
|
+
"FieldMetric",
|
|
16
|
+
"FieldMetricConfig",
|
|
17
|
+
"FieldMetricMapping",
|
|
18
|
+
"IntermediateResults",
|
|
19
|
+
"Levenshtein",
|
|
20
|
+
"Record",
|
|
21
|
+
"RecordCollection",
|
|
22
|
+
]
|
bibraeval/aggregator.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Aggregation of field-level comparisons into macro-averaged metrics."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from bibraeval.comparator import Comparator
|
|
10
|
+
|
|
11
|
+
AggregationMode = Literal["doc-avg", "field-avg", "micro-avg"]
|
|
12
|
+
|
|
13
|
+
METRICS = ("f1", "prec", "rec")
|
|
14
|
+
_PRIMARY_KEYS = ("doc_id", "field_name")
|
|
15
|
+
_UNGROUPABLE = ("gt_value", "pred_value")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class IntermediateResults:
|
|
20
|
+
"""Per-group metrics and the grouping metadata used to produce them.
|
|
21
|
+
|
|
22
|
+
``data`` contains grouping columns, precision/recall/F1, and support
|
|
23
|
+
counts. ``group_by`` records the grouping columns; ``mode`` identifies
|
|
24
|
+
document-, field-, or micro-averaging.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
data: pl.DataFrame
|
|
28
|
+
group_by: tuple[str, ...]
|
|
29
|
+
mode: AggregationMode
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def strata(self) -> list[str]:
|
|
33
|
+
"""Grouping columns other than the built-in document/field keys."""
|
|
34
|
+
return [col for col in self.group_by if col not in _PRIMARY_KEYS]
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def weighted_summary(self) -> bool:
|
|
38
|
+
"""Whether field weights are applied when producing calling the
|
|
39
|
+
summarise_results method."""
|
|
40
|
+
return "field_name" in self.group_by
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Aggregator:
|
|
44
|
+
"""Aggregate a comparator's field-level scores into precision/recall/F1.
|
|
45
|
+
|
|
46
|
+
Cell scores are computed at initialization. Add any custom strata columns to
|
|
47
|
+
``comparator.comparison_matrix`` before constructing the aggregator.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, comparator: Comparator):
|
|
51
|
+
self.comparator = comparator
|
|
52
|
+
self.intermediate_results: IntermediateResults | None = None
|
|
53
|
+
self.comparison_matrix = self.comparator.compute_cell_agreement()
|
|
54
|
+
self.cells = self._compute_cell_scores(self.comparison_matrix)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _compute_cell_scores(cells: pl.DataFrame) -> pl.DataFrame:
|
|
58
|
+
"""Mask cell agreement by presence for precision and recall."""
|
|
59
|
+
agreement = pl.col("cell_agreement")
|
|
60
|
+
return cells.with_columns(
|
|
61
|
+
pl.when(~pl.col("pred_present"))
|
|
62
|
+
.then(None)
|
|
63
|
+
.when(pl.col("gt_present"))
|
|
64
|
+
.then(agreement)
|
|
65
|
+
.otherwise(0.0)
|
|
66
|
+
.cast(pl.Float64)
|
|
67
|
+
.alias("prec_score"),
|
|
68
|
+
pl.when(~pl.col("gt_present"))
|
|
69
|
+
.then(None)
|
|
70
|
+
.when(pl.col("pred_present"))
|
|
71
|
+
.then(agreement)
|
|
72
|
+
.otherwise(0.0)
|
|
73
|
+
.cast(pl.Float64)
|
|
74
|
+
.alias("rec_score"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def compute_intermediate_results(
|
|
78
|
+
self, group_by: str | Sequence[str] | None
|
|
79
|
+
) -> IntermediateResults:
|
|
80
|
+
"""Compute precision, recall and f1 per group.
|
|
81
|
+
|
|
82
|
+
For macro averaging `group_by` must contain exactly one of `doc_id` or
|
|
83
|
+
`field_name`; other columns may be included as strata.
|
|
84
|
+
When grouping by `field_name`, field weights are carried to the summary
|
|
85
|
+
step; otherwise they are applied as weighted means within each group.
|
|
86
|
+
|
|
87
|
+
Set ``group_by=None`` for micro-averaging. For a stratified micro
|
|
88
|
+
average, group only by the desired (additional) stratification columns.
|
|
89
|
+
Value columns cannot be used as grouping keys.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If a grouping column is missing, a value column is
|
|
93
|
+
requested, or both primary keys are included.
|
|
94
|
+
"""
|
|
95
|
+
if group_by is None:
|
|
96
|
+
group_cols = ()
|
|
97
|
+
elif isinstance(group_by, str):
|
|
98
|
+
group_cols = (group_by,)
|
|
99
|
+
else:
|
|
100
|
+
group_cols = tuple(group_by)
|
|
101
|
+
self._validate_group_by(group_cols)
|
|
102
|
+
|
|
103
|
+
weighted_summary = "field_name" in group_cols
|
|
104
|
+
aggs: list[pl.Expr] = []
|
|
105
|
+
for name, score in (("prec", "prec_score"), ("rec", "rec_score")):
|
|
106
|
+
aggs.append(pl.col(score).count().alias(f"support_{name}"))
|
|
107
|
+
if weighted_summary:
|
|
108
|
+
aggs.append(pl.col(score).mean().alias(name))
|
|
109
|
+
else:
|
|
110
|
+
aggs.append(_weighted_mean(pl.col(score), pl.col("weight")).alias(name))
|
|
111
|
+
if weighted_summary:
|
|
112
|
+
aggs.append(pl.col("weight").first())
|
|
113
|
+
|
|
114
|
+
if group_cols:
|
|
115
|
+
data = self.cells.group_by(group_cols, maintain_order=True).agg(aggs)
|
|
116
|
+
else:
|
|
117
|
+
data = self.cells.select(aggs)
|
|
118
|
+
|
|
119
|
+
data = data.with_columns(_f1(pl.col("prec"), pl.col("rec")).alias("f1"))
|
|
120
|
+
if group_cols:
|
|
121
|
+
data = data.sort(group_cols)
|
|
122
|
+
|
|
123
|
+
self.intermediate_results = IntermediateResults(
|
|
124
|
+
data=data, group_by=group_cols, mode=_mode(group_cols)
|
|
125
|
+
)
|
|
126
|
+
return self.intermediate_results
|
|
127
|
+
|
|
128
|
+
def _validate_group_by(self, group_cols: tuple[str, ...]) -> None:
|
|
129
|
+
if all(col in group_cols for col in _PRIMARY_KEYS):
|
|
130
|
+
raise ValueError("group_by cannot include both 'doc_id' and 'field_name'.")
|
|
131
|
+
missing = [col for col in group_cols if col not in self.cells.columns]
|
|
132
|
+
if missing:
|
|
133
|
+
raise ValueError(f"group_by columns not found: {missing}")
|
|
134
|
+
invalid = [col for col in group_cols if col in _UNGROUPABLE]
|
|
135
|
+
if invalid:
|
|
136
|
+
raise ValueError(f"Cannot group by value columns: {invalid}")
|
|
137
|
+
|
|
138
|
+
def summarise_results(
|
|
139
|
+
self, intermediate: IntermediateResults | None = None
|
|
140
|
+
) -> pl.DataFrame:
|
|
141
|
+
"""Summarize intermediate results while preserving strata.
|
|
142
|
+
|
|
143
|
+
Macro modes average the intermediate results; micro mode returns its
|
|
144
|
+
pooled result without another aggregation. The long-form output has one
|
|
145
|
+
row per metric and stratum, with ``metric``, ``mode``, ``value``, and
|
|
146
|
+
``support`` columns in addition to any strata columns.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If no intermediate results are available and none are
|
|
150
|
+
passed explicitly.
|
|
151
|
+
"""
|
|
152
|
+
if intermediate is None:
|
|
153
|
+
intermediate = self.intermediate_results
|
|
154
|
+
if intermediate is None:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
"No intermediate results; call compute_intermediate_results first."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
strata = intermediate.strata
|
|
160
|
+
index = strata + (["weight"] if intermediate.weighted_summary else [])
|
|
161
|
+
long_index = (
|
|
162
|
+
[*index, "support_prec", "support_rec"]
|
|
163
|
+
if intermediate.mode in ["micro-avg", "field-avg"]
|
|
164
|
+
else index
|
|
165
|
+
)
|
|
166
|
+
long = intermediate.data.select(*long_index, *METRICS).unpivot(
|
|
167
|
+
on=list(METRICS), index=long_index, variable_name="metric"
|
|
168
|
+
)
|
|
169
|
+
value = (
|
|
170
|
+
_weighted_mean(pl.col("value"), pl.col("weight"))
|
|
171
|
+
if intermediate.weighted_summary
|
|
172
|
+
else pl.col("value").mean()
|
|
173
|
+
)
|
|
174
|
+
support = (
|
|
175
|
+
pl.when(pl.col("metric") == "prec")
|
|
176
|
+
.then(pl.col("support_prec"))
|
|
177
|
+
.when(pl.col("metric") == "rec")
|
|
178
|
+
.then(pl.col("support_rec"))
|
|
179
|
+
.otherwise(pl.max_horizontal("support_prec", "support_rec"))
|
|
180
|
+
.mean()
|
|
181
|
+
if intermediate.mode in ["micro-avg", "field-avg"]
|
|
182
|
+
else pl.col("value").count()
|
|
183
|
+
)
|
|
184
|
+
return (
|
|
185
|
+
long.group_by([*strata, "metric"])
|
|
186
|
+
.agg(value.alias("value"), support.alias("support"))
|
|
187
|
+
.with_columns(pl.lit(intermediate.mode).alias("mode"))
|
|
188
|
+
.select(*strata, "metric", "mode", "value", "support")
|
|
189
|
+
.sort([*strata, "metric"])
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _weighted_mean(score: pl.Expr, weight: pl.Expr) -> pl.Expr:
|
|
194
|
+
"""Weighted mean over non-null scores; null if no weight remains."""
|
|
195
|
+
weight_sum = weight.filter(score.is_not_null()).sum()
|
|
196
|
+
return (
|
|
197
|
+
pl.when(weight_sum > 0)
|
|
198
|
+
.then((score * weight).sum() / weight_sum)
|
|
199
|
+
.otherwise(None)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _f1(prec: pl.Expr, rec: pl.Expr) -> pl.Expr:
|
|
204
|
+
return (
|
|
205
|
+
pl.when(prec.is_null() & rec.is_null())
|
|
206
|
+
.then(None)
|
|
207
|
+
.when(prec.is_null() | rec.is_null() | ((prec + rec) == 0))
|
|
208
|
+
.then(0.0)
|
|
209
|
+
.otherwise(2 * prec * rec / (prec + rec))
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _mode(group_cols: tuple[str, ...]) -> AggregationMode:
|
|
214
|
+
if "doc_id" in group_cols:
|
|
215
|
+
return "doc-avg"
|
|
216
|
+
if "field_name" in group_cols:
|
|
217
|
+
return "field-avg"
|
|
218
|
+
return "micro-avg"
|
bibraeval/comparator.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
|
|
5
|
+
from bibraeval.data_ingester import MetadataT, RecordCollection
|
|
6
|
+
from bibraeval.field_metrics import Exact, FieldMetric
|
|
7
|
+
from bibraeval.metric_conf import FieldMetricMapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Comparator:
|
|
11
|
+
"""Match two record collections and score their metadata fields.
|
|
12
|
+
|
|
13
|
+
The comparison matrix contains one row per document and metadata field.
|
|
14
|
+
Document ID sets must match unless ``drop_mismatched_doc_ids`` is enabled;
|
|
15
|
+
in that case only IDs present in both collections are compared.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
ground_truth: RecordCollection[MetadataT],
|
|
21
|
+
predictions: RecordCollection[MetadataT],
|
|
22
|
+
metric_schema: FieldMetricMapping | dict[str, Any] | None = None,
|
|
23
|
+
drop_mismatched_doc_ids: bool = False,
|
|
24
|
+
):
|
|
25
|
+
"""Create a comparator for validated record collections.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
ground_truth: Reference records keyed by document ID.
|
|
29
|
+
predictions: Predicted records keyed by document ID.
|
|
30
|
+
metric_schema: Per-field metric configuration. Unlisted fields are
|
|
31
|
+
omitted from scoring when a schema is supplied.
|
|
32
|
+
drop_mismatched_doc_ids: If true, compare only IDs in both
|
|
33
|
+
collections; otherwise a mismatch raises ``ValueError``.
|
|
34
|
+
"""
|
|
35
|
+
self.ground_truth = ground_truth
|
|
36
|
+
self.predictions = predictions
|
|
37
|
+
self.drop_mismatched_doc_ids = drop_mismatched_doc_ids
|
|
38
|
+
if isinstance(metric_schema, dict):
|
|
39
|
+
metric_schema = FieldMetricMapping.from_dict(metric_schema)
|
|
40
|
+
self.metric_schema = metric_schema
|
|
41
|
+
|
|
42
|
+
if not self.drop_mismatched_doc_ids:
|
|
43
|
+
self._check_doc_id_match()
|
|
44
|
+
self.comparison_matrix = self._fuse_records()
|
|
45
|
+
|
|
46
|
+
def _check_doc_id_match(self) -> bool:
|
|
47
|
+
"""Check if two record collections contain the same doc_ids."""
|
|
48
|
+
gt_doc_ids = set(self.ground_truth.records)
|
|
49
|
+
pred_doc_ids = set(self.predictions.records)
|
|
50
|
+
if gt_doc_ids == pred_doc_ids:
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
mismatches_gt_not_pred = gt_doc_ids - pred_doc_ids
|
|
54
|
+
mismatches_pred_not_gold = pred_doc_ids - gt_doc_ids
|
|
55
|
+
n_mismatches = len(gt_doc_ids ^ pred_doc_ids)
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Record collections have {n_mismatches} mismatched doc_ids: "
|
|
58
|
+
f"ground_truth not in predictions: {mismatches_gt_not_pred}, "
|
|
59
|
+
f"predictions not in ground_truth: {mismatches_pred_not_gold}"
|
|
60
|
+
f"Use drop_mismatched_doc_ids=True to ignore mismatched doc_ids."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _fuse_records(self) -> pl.DataFrame:
|
|
64
|
+
"""Fuse records into one long Polars DataFrame by doc_id and field.
|
|
65
|
+
|
|
66
|
+
The ``gt_present`` and ``pred_present`` flags are false for ``None``,
|
|
67
|
+
empty strings, and empty lists. Other values, including ``0`` and
|
|
68
|
+
``False``, count as present.
|
|
69
|
+
|
|
70
|
+
Columns returned:
|
|
71
|
+
- `doc_id`
|
|
72
|
+
- `field_name`
|
|
73
|
+
- `gt_present`
|
|
74
|
+
- `pred_present`
|
|
75
|
+
- `gt_value` (may be nested for list fields)
|
|
76
|
+
- `pred_value` (may be nested for list fields)
|
|
77
|
+
"""
|
|
78
|
+
fused_data = []
|
|
79
|
+
for doc_id in sorted(self.ground_truth.records):
|
|
80
|
+
if self.drop_mismatched_doc_ids and doc_id not in self.predictions.records:
|
|
81
|
+
continue
|
|
82
|
+
gt_record = self.ground_truth.records[doc_id]
|
|
83
|
+
pred_record = self.predictions.records[doc_id]
|
|
84
|
+
gt_metadata = gt_record.metadata.model_dump(by_alias=True)
|
|
85
|
+
pred_metadata = pred_record.metadata.model_dump(by_alias=True)
|
|
86
|
+
field_names = sorted(gt_metadata.keys() | pred_metadata.keys())
|
|
87
|
+
|
|
88
|
+
for field_name in field_names:
|
|
89
|
+
gt_value = gt_metadata.get(field_name)
|
|
90
|
+
pred_value = pred_metadata.get(field_name)
|
|
91
|
+
fused_data.append(
|
|
92
|
+
{
|
|
93
|
+
"doc_id": doc_id,
|
|
94
|
+
"field_name": field_name,
|
|
95
|
+
# custom test for presence of value, exclusing empty
|
|
96
|
+
# strings and empty lists
|
|
97
|
+
"gt_present": self._is_present(gt_value),
|
|
98
|
+
"pred_present": self._is_present(pred_value),
|
|
99
|
+
"gt_value": gt_value,
|
|
100
|
+
"pred_value": pred_value,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return pl.DataFrame(
|
|
105
|
+
fused_data,
|
|
106
|
+
schema_overrides={"gt_value": pl.Object, "pred_value": pl.Object},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _is_present(value: object) -> bool:
|
|
111
|
+
return value is not None and value != "" and value != []
|
|
112
|
+
|
|
113
|
+
def compute_cell_agreement(
|
|
114
|
+
self,
|
|
115
|
+
comparison_matrix: pl.DataFrame | None = None,
|
|
116
|
+
metric: FieldMetric | None = None,
|
|
117
|
+
) -> pl.DataFrame:
|
|
118
|
+
"""Compute cell-level agreement between gold and predicted values.
|
|
119
|
+
|
|
120
|
+
With a `metric_schema`, only fields listed in the schema are scored, each
|
|
121
|
+
with its configured metric and weight. Otherwise all fields are scored
|
|
122
|
+
with `metric` (default `Exact()`) and weight 1.0.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
comparison_matrix: Matrix to score; defaults to this comparator's
|
|
126
|
+
matrix. It must contain ``field_name``, ``gt_value``, and
|
|
127
|
+
``pred_value`` columns. Additional columns may be present to
|
|
128
|
+
facilitate stratified analysis.
|
|
129
|
+
metric: Metric used when no metric schema was supplied.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If both ``metric`` and a configured metric schema are
|
|
133
|
+
supplied.
|
|
134
|
+
"""
|
|
135
|
+
if comparison_matrix is None:
|
|
136
|
+
comparison_matrix = self.comparison_matrix
|
|
137
|
+
|
|
138
|
+
if self.metric_schema is not None:
|
|
139
|
+
if metric is not None:
|
|
140
|
+
raise ValueError("Pass either metric or metric_schema, not both.")
|
|
141
|
+
return self._compute_schema_agreement(comparison_matrix, self.metric_schema)
|
|
142
|
+
|
|
143
|
+
if metric is None:
|
|
144
|
+
metric = Exact()
|
|
145
|
+
|
|
146
|
+
scores = [
|
|
147
|
+
metric.score(gt_value, pred_value)
|
|
148
|
+
for gt_value, pred_value in comparison_matrix.select(
|
|
149
|
+
"gt_value", "pred_value"
|
|
150
|
+
).iter_rows()
|
|
151
|
+
]
|
|
152
|
+
return comparison_matrix.with_columns(
|
|
153
|
+
pl.Series("cell_agreement", scores, dtype=pl.Float64),
|
|
154
|
+
pl.lit(1.0, dtype=pl.Float64).alias("weight"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _compute_schema_agreement(
|
|
158
|
+
self, comparison_matrix: pl.DataFrame, schema: FieldMetricMapping
|
|
159
|
+
) -> pl.DataFrame:
|
|
160
|
+
comparison_matrix = comparison_matrix.filter(
|
|
161
|
+
pl.col("field_name").is_in(list(schema.fields))
|
|
162
|
+
)
|
|
163
|
+
metrics = {name: schema.build_metric(name) for name in schema.fields}
|
|
164
|
+
|
|
165
|
+
scores = [
|
|
166
|
+
metrics[field_name].score(gt_value, pred_value)
|
|
167
|
+
for field_name, gt_value, pred_value in comparison_matrix.select(
|
|
168
|
+
"field_name", "gt_value", "pred_value"
|
|
169
|
+
).iter_rows()
|
|
170
|
+
]
|
|
171
|
+
weights = [
|
|
172
|
+
schema.fields[field_name].weight
|
|
173
|
+
for field_name in comparison_matrix["field_name"]
|
|
174
|
+
]
|
|
175
|
+
return comparison_matrix.with_columns(
|
|
176
|
+
pl.Series("cell_agreement", scores, dtype=pl.Float64),
|
|
177
|
+
pl.Series("weight", weights, dtype=pl.Float64),
|
|
178
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
MetadataT = TypeVar("MetadataT", bound=BaseModel)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Record[MetadataT: BaseModel](BaseModel):
|
|
13
|
+
"""A record with a stable document identifier and validated metadata.
|
|
14
|
+
|
|
15
|
+
``metadata`` is parsed with the caller-supplied Pydantic model; ``doc_id``
|
|
16
|
+
is kept separately so records can be matched independently of row order.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
doc_id: str
|
|
20
|
+
metadata: MetadataT
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_payload(
|
|
24
|
+
cls,
|
|
25
|
+
doc_id: str,
|
|
26
|
+
payload: dict[str, Any],
|
|
27
|
+
schema: type[MetadataT],
|
|
28
|
+
) -> Record[MetadataT]:
|
|
29
|
+
"""Validate a metadata payload and create a record for ``doc_id``.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
doc_id: Stable identifier used to match this record across datasets.
|
|
33
|
+
payload: Metadata fields accepted by ``schema``.
|
|
34
|
+
schema: Pydantic model used to validate the metadata.
|
|
35
|
+
"""
|
|
36
|
+
validated_metadata = schema.model_validate(payload)
|
|
37
|
+
return cls(doc_id=doc_id, metadata=validated_metadata)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RecordCollection[MetadataT: BaseModel](BaseModel):
|
|
41
|
+
"""A collection of records keyed by document identifier."""
|
|
42
|
+
|
|
43
|
+
records: dict[str, Record[MetadataT]] = Field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def create_from_dir(
|
|
47
|
+
cls,
|
|
48
|
+
directory: str | Path,
|
|
49
|
+
schema: type[MetadataT],
|
|
50
|
+
) -> RecordCollection[MetadataT]:
|
|
51
|
+
"""Load ``*.json`` records from a directory, using each filename stem
|
|
52
|
+
as its document ID.
|
|
53
|
+
|
|
54
|
+
Files must contain JSON objects. Other files and nested directories are
|
|
55
|
+
ignored.
|
|
56
|
+
"""
|
|
57
|
+
collection = cls()
|
|
58
|
+
for record_path in sorted(Path(directory).glob("*.json")):
|
|
59
|
+
with record_path.open(encoding="utf-8") as record_file:
|
|
60
|
+
payload = json.load(record_file)
|
|
61
|
+
if not isinstance(payload, dict):
|
|
62
|
+
msg = f"Record file {record_path} must contain a JSON object."
|
|
63
|
+
raise TypeError(msg)
|
|
64
|
+
collection.add_payload(
|
|
65
|
+
doc_id=record_path.stem,
|
|
66
|
+
payload=payload,
|
|
67
|
+
schema=schema,
|
|
68
|
+
)
|
|
69
|
+
return collection
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def create_from_file(
|
|
73
|
+
cls,
|
|
74
|
+
file_path: str | Path,
|
|
75
|
+
schema: type[MetadataT],
|
|
76
|
+
) -> RecordCollection[MetadataT]:
|
|
77
|
+
"""Load JSONL records whose objects each contain a non-empty ``doc_id``.
|
|
78
|
+
|
|
79
|
+
Blank lines are ignored. Duplicate IDs replace the earlier record.
|
|
80
|
+
"""
|
|
81
|
+
collection = cls()
|
|
82
|
+
with Path(file_path).open(encoding="utf-8") as jsonl_file:
|
|
83
|
+
for line_number, line in enumerate(jsonl_file, start=1):
|
|
84
|
+
if not line.strip():
|
|
85
|
+
continue
|
|
86
|
+
payload = json.loads(line)
|
|
87
|
+
if not isinstance(payload, dict):
|
|
88
|
+
msg = (
|
|
89
|
+
f"Line {line_number} in {file_path} must contain a JSON object."
|
|
90
|
+
)
|
|
91
|
+
raise TypeError(msg)
|
|
92
|
+
doc_id = payload.get("doc_id")
|
|
93
|
+
if not isinstance(doc_id, str) or not doc_id:
|
|
94
|
+
msg = f"Line {line_number} in {file_path} must contain a doc_id."
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
raw_record = payload.get("record")
|
|
97
|
+
if not isinstance(raw_record, dict) or not raw_record:
|
|
98
|
+
msg = (
|
|
99
|
+
f"Line {line_number} in {file_path} "
|
|
100
|
+
"must contain a record object."
|
|
101
|
+
)
|
|
102
|
+
raise TypeError(msg)
|
|
103
|
+
collection.add_payload(
|
|
104
|
+
doc_id=doc_id,
|
|
105
|
+
payload=raw_record,
|
|
106
|
+
schema=schema,
|
|
107
|
+
)
|
|
108
|
+
return collection
|
|
109
|
+
|
|
110
|
+
def add_record(self, record: Record[MetadataT]) -> None:
|
|
111
|
+
"""Store a record, replacing any existing record with the same ID."""
|
|
112
|
+
self.records[record.doc_id] = record
|
|
113
|
+
|
|
114
|
+
def get_record(self, doc_id: str) -> Record[MetadataT] | None:
|
|
115
|
+
"""Return a record by ID, or ``None`` when the ID is absent."""
|
|
116
|
+
return self.records.get(doc_id)
|
|
117
|
+
|
|
118
|
+
def add_payload(
|
|
119
|
+
self,
|
|
120
|
+
doc_id: str,
|
|
121
|
+
payload: dict[str, Any],
|
|
122
|
+
schema: type[MetadataT],
|
|
123
|
+
) -> Record[MetadataT]:
|
|
124
|
+
"""Validate payload against metadata schema and store
|
|
125
|
+
payload, replacing an existing matching ID."""
|
|
126
|
+
record = Record.from_payload(doc_id=doc_id, payload=payload, schema=schema)
|
|
127
|
+
self.add_record(record)
|
|
128
|
+
return record
|
|
129
|
+
|
|
130
|
+
def as_list(self) -> list[Record[MetadataT]]:
|
|
131
|
+
"""Return the stored records as a list in insertion order."""
|
|
132
|
+
return list(self.records.values())
|