"""Field-level conflict resolution for multi-source records. When more than one source describes the same product, each field may have several candidate values. We pick a winner per field by source trust first, then recency, ignoring empty values, and keep a provenance trail of which source won each field. These are pure functions (no DB / no network) so they are easy to unit-test; the DB-level record merge lives in :mod:`opengoods.etl.dedup`. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime @dataclass(frozen=True) class Candidate: """One source's proposed value for a field.""" value: object source: str trust: float = 0.5 fetched_at: datetime | None = None @dataclass class FieldResolution: """The winning value for a field plus the source it came from.""" value: object source: str | None = None @dataclass class MergedRecord: """A merged record with per-field provenance (field name -> source).""" values: dict[str, object] = field(default_factory=dict) provenance: dict[str, str] = field(default_factory=dict) def _is_empty(value: object) -> bool: if value is None: return True if isinstance(value, str): return value.strip() == "" if isinstance(value, (list, dict, tuple, set)): return len(value) == 0 return False def _sort_key(c: Candidate) -> tuple[float, float]: ts = c.fetched_at.timestamp() if c.fetched_at is not None else float("-inf") return (c.trust, ts) def resolve_field(candidates: list[Candidate]) -> FieldResolution | None: """Pick the best non-empty candidate for one field. Ranking: highest source trust, then most recent ``fetched_at``. Returns ``None`` when there is no usable (non-empty) candidate. """ usable = [c for c in candidates if not _is_empty(c.value)] if not usable: return None winner = max(usable, key=_sort_key) return FieldResolution(value=winner.value, source=winner.source) def merge_records(records: list[dict], *, fields: list[str] | None = None) -> MergedRecord: """Merge several ``{field: Candidate|value}`` records into one. Each input record maps field name -> :class:`Candidate` (preferred) or a bare value (treated as trust 0.5, no timestamp). The result keeps, for each field, the winning value and the name of the source that supplied it. """ keys: list[str] if fields is not None: keys = list(fields) else: seen: dict[str, None] = {} for rec in records: for k in rec: seen.setdefault(k, None) keys = list(seen) merged = MergedRecord() for key in keys: candidates: list[Candidate] = [] for rec in records: if key not in rec: continue cand = rec[key] if not isinstance(cand, Candidate): cand = Candidate(value=cand, source="unknown") candidates.append(cand) resolution = resolve_field(candidates) if resolution is not None: merged.values[key] = resolution.value if resolution.source is not None: merged.provenance[key] = resolution.source return merged