M4: ingestion management (incremental, GS1 supplement, dedup/conflict, quality, scheduler)
CI / Go (api) (pull_request) Has been cancelled
CI / Python (ingestion) (pull_request) Has been cancelled
CI / Migrations (postgres) (pull_request) Has been cancelled

- OFF incremental fetch via search API + persistent watermark (ingest_state, migration 0004)
- GS1 barcode supplement adapter (offline mapping + GS1-style API) filling only gaps with field-level provenance
- Non-GTIN dedup with canonical selection + merge_log; field-level conflict resolution (source trust > recency)
- Quality scoring (0.4 completeness + 0.3 source trust + 0.2 multi-source + 0.1 freshness) wired into load/merge
- Jobs: update_off, dedup, schedule; docs/ingestion-management.md
- 19 new tests (pure + DB-integration), ruff clean

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
John Doe
2026-06-08 09:27:42 +00:00
parent e750501b44
commit a35bcd6647
23 changed files with 1387 additions and 3 deletions
+139
View File
@@ -0,0 +1,139 @@
"""Duplicate detection and product merging.
Barcodes (GTIN) are already unique at the schema level, so duplicates here are
non-GTIN records that describe the same product (same normalized name + brand +
net content). For each duplicate group we keep the highest-quality product as
canonical and merge the rest into it: child rows (provenance, images, MSRP) are
re-pointed to the canonical product, the merged product is marked ``merged``
with ``canonical_id`` set, and a row is written to ``merge_log``.
"""
from __future__ import annotations
from typing import Any
import psycopg
from opengoods.etl.quality import update_quality
def _norm(text: str | None) -> str:
return " ".join((text or "").lower().split())
def product_signature(name: str | None, brand: str | None, net_canonical: Any | None) -> str | None:
"""Stable signature for non-GTIN dedup, or ``None`` if too sparse to match."""
n = _norm(name)
if not n:
return None
net = "" if net_canonical is None else str(net_canonical)
return f"{n}|{_norm(brand)}|{net}"
def choose_canonical(members: list[dict]) -> dict:
"""Pick the canonical product: best quality, then oldest, then lowest id."""
return min(
members,
key=lambda m: (
-float(m.get("quality_score") or 0.0),
m.get("created_at"),
str(m.get("id")),
),
)
def find_duplicate_groups(conn: psycopg.Connection) -> list[list[dict]]:
"""Return groups (size >= 2) of active products sharing a signature."""
rows = conn.execute(
"""
SELECT p.id, p.name, b.normalized_name, p.net_content_canonical,
p.quality_score, p.created_at
FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
WHERE p.status = 'active'
"""
).fetchall()
groups: dict[str, list[dict]] = {}
for r in rows:
sig = product_signature(r[1], r[2], r[3])
if sig is None:
continue
member = {
"id": r[0],
"name": r[1],
"quality_score": r[4],
"created_at": r[5],
}
groups.setdefault(sig, []).append(member)
return [m for m in groups.values() if len(m) >= 2]
def merge_products(
conn: psycopg.Connection,
kept_id: str,
merged_id: str,
reason: str = "auto-dedup",
actor: str = "ingestion",
) -> None:
"""Merge ``merged_id`` into ``kept_id`` (re-point children, mark merged)."""
if kept_id == merged_id:
return
# Re-point provenance, images and MSRP to the canonical product.
conn.execute(
"UPDATE product_source SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
conn.execute(
"UPDATE product_image SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
conn.execute(
"UPDATE product_msrp SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
# food_detail has product_id as PK, so it can only move if the canonical
# product does not already have one.
kept_has_food = conn.execute(
"SELECT 1 FROM food_detail WHERE product_id = %s", (kept_id,)
).fetchone()
if not kept_has_food:
conn.execute(
"UPDATE food_detail SET product_id = %s WHERE product_id = %s",
(kept_id, merged_id),
)
conn.execute(
"UPDATE product SET status = 'merged', canonical_id = %s WHERE id = %s",
(kept_id, merged_id),
)
conn.execute(
"""
INSERT INTO merge_log (kept_id, merged_id, reason, actor)
VALUES (%s, %s, %s, %s)
""",
(kept_id, merged_id, reason, actor),
)
# The canonical product gained sources, so its quality may have changed.
update_quality(conn, kept_id)
def dedup_all(
conn: psycopg.Connection, actor: str = "ingestion", dry_run: bool = False
) -> dict[str, int]:
"""Merge every duplicate group. Returns counts of groups and merges."""
groups = find_duplicate_groups(conn)
merged = 0
for members in groups:
canonical = choose_canonical(members)
for m in members:
if m["id"] == canonical["id"]:
continue
if not dry_run:
merge_products(conn, canonical["id"], m["id"], actor=actor)
merged += 1
return {"groups": len(groups), "merged": merged}
+18 -3
View File
@@ -14,6 +14,7 @@ import psycopg
from psycopg.types.json import Jsonb
from opengoods.adapters.openfoodfacts import OFF_LICENSE, SOURCE_NAME
from opengoods.etl.quality import update_quality
OFF_HOMEPAGE = "https://world.openfoodfacts.org"
@@ -29,8 +30,14 @@ def _normalize_brand(name: str) -> str:
return " ".join(name.lower().split())
def ensure_source(conn: psycopg.Connection) -> str:
"""Upsert the Open Food Facts source row and return its id."""
def ensure_source_named(
conn: psycopg.Connection,
name: str,
homepage: str,
license: str,
trust_weight: float,
) -> str:
"""Upsert a source row by name and return its id."""
row = conn.execute(
"""
INSERT INTO source (name, homepage, license, trust_weight)
@@ -38,11 +45,16 @@ def ensure_source(conn: psycopg.Connection) -> str:
ON CONFLICT (name) DO UPDATE SET homepage = EXCLUDED.homepage
RETURNING id
""",
(SOURCE_NAME, OFF_HOMEPAGE, OFF_LICENSE, 0.7),
(name, homepage, license, trust_weight),
).fetchone()
return row[0]
def ensure_source(conn: psycopg.Connection) -> str:
"""Upsert the Open Food Facts source row and return its id."""
return ensure_source_named(conn, SOURCE_NAME, OFF_HOMEPAGE, OFF_LICENSE, 0.7)
def _ensure_brand(conn: psycopg.Connection, name: str | None) -> str | None:
if not name:
return None
@@ -178,6 +190,9 @@ def load_record(conn: psycopg.Connection, rec: dict[str, Any], source_id: str, r
Jsonb(_jsonable(raw)),
),
)
# Recompute the data-quality score now that all facts + provenance exist.
update_quality(conn, product_id)
return product_id
+104
View File
@@ -0,0 +1,104 @@
"""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
+176
View File
@@ -0,0 +1,176 @@
"""Product data-quality scoring.
The quality score is a 0..1 number combining four signals, per the locked
project decision:
quality = 0.4 * completeness
+ 0.3 * source_trust
+ 0.2 * multi_source_agreement
+ 0.1 * freshness
Each component is itself normalized to 0..1. The pure helpers below are
unit-testable; :func:`compute_quality` / :func:`update_quality` read the signals
for a product out of the database and persist the result on ``product``.
"""
from __future__ import annotations
from datetime import UTC, datetime
import psycopg
W_COMPLETENESS = 0.4
W_SOURCE_TRUST = 0.3
W_AGREEMENT = 0.2
W_FRESHNESS = 0.1
# Fields that count towards completeness (weighted equally).
COMPLETENESS_FIELDS = (
"name",
"gtin",
"brand",
"category",
"net_content",
"country_of_origin",
"nutriments",
"ingredients",
"image",
)
def completeness(present: set[str]) -> float:
"""Fraction of :data:`COMPLETENESS_FIELDS` that are present for a product."""
if not COMPLETENESS_FIELDS:
return 0.0
hits = sum(1 for f in COMPLETENESS_FIELDS if f in present)
return hits / len(COMPLETENESS_FIELDS)
def agreement_from_sources(source_count: int) -> float:
"""Multi-source corroboration proxy from the number of distinct sources.
A single source cannot be corroborated, so it scores a neutral 0.5; more
independent sources that describe the same product raise confidence.
"""
if source_count <= 1:
return 0.5
if source_count == 2:
return 0.8
return 1.0
def freshness_from_age(age_days: float | None) -> float:
"""Recency score from the age (in days) of the most recent source fetch."""
if age_days is None:
return 0.5
if age_days <= 30:
return 1.0
if age_days <= 180:
return 0.8
if age_days <= 365:
return 0.6
if age_days <= 730:
return 0.4
return 0.2
def score(
*,
completeness_score: float,
source_trust: float,
agreement: float,
freshness: float,
) -> float:
"""Combine the four normalized components into a 0..1 quality score."""
raw = (
W_COMPLETENESS * completeness_score
+ W_SOURCE_TRUST * source_trust
+ W_AGREEMENT * agreement
+ W_FRESHNESS * freshness
)
return round(max(0.0, min(1.0, raw)), 3)
def _present_fields(prod: dict, has_image: bool) -> set[str]:
present: set[str] = set()
if prod.get("name"):
present.add("name")
if prod.get("gtin"):
present.add("gtin")
if prod.get("brand_id"):
present.add("brand")
if prod.get("category_id"):
present.add("category")
if prod.get("net_content_canonical") is not None:
present.add("net_content")
if prod.get("country_of_origin"):
present.add("country_of_origin")
if prod.get("nutriments"):
present.add("nutriments")
if prod.get("ingredients_text"):
present.add("ingredients")
if has_image:
present.add("image")
return present
def compute_quality(conn: psycopg.Connection, product_id: str) -> float:
"""Compute (but do not persist) the quality score for one product."""
row = conn.execute(
"""
SELECT p.name, p.gtin, p.brand_id, p.category_id, p.net_content_canonical,
p.country_of_origin, f.nutriments, f.ingredients_text,
EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id)
FROM product p
LEFT JOIN food_detail f ON f.product_id = p.id
WHERE p.id = %s
""",
(product_id,),
).fetchone()
if row is None:
return 0.0
prod = {
"name": row[0],
"gtin": row[1],
"brand_id": row[2],
"category_id": row[3],
"net_content_canonical": row[4],
"country_of_origin": row[5],
"nutriments": row[6],
"ingredients_text": row[7],
}
has_image = bool(row[8])
src = conn.execute(
"""
SELECT count(DISTINCT ps.source_id), COALESCE(max(s.trust_weight), 0), max(ps.fetched_at)
FROM product_source ps
LEFT JOIN source s ON s.id = ps.source_id
WHERE ps.product_id = %s
""",
(product_id,),
).fetchone()
source_count = int(src[0] or 0)
source_trust = float(src[1] or 0.0)
last_fetched: datetime | None = src[2]
age_days: float | None = None
if last_fetched is not None:
now = datetime.now(UTC)
if last_fetched.tzinfo is None:
last_fetched = last_fetched.replace(tzinfo=UTC)
age_days = max(0.0, (now - last_fetched).total_seconds() / 86400.0)
return score(
completeness_score=completeness(_present_fields(prod, has_image)),
source_trust=source_trust,
agreement=agreement_from_sources(source_count),
freshness=freshness_from_age(age_days),
)
def update_quality(conn: psycopg.Connection, product_id: str) -> float:
"""Compute the quality score and write it to ``product.quality_score``."""
value = compute_quality(conn, product_id)
conn.execute("UPDATE product SET quality_score = %s WHERE id = %s", (value, product_id))
return value
+45
View File
@@ -0,0 +1,45 @@
"""Persistent ingestion watermark stored in the ``ingest_state`` table.
The incremental updater uses this to remember how far it got for each source
(e.g. Open Food Facts exposes a ``last_modified_t`` unix timestamp on every
product) so repeated runs only fetch what changed.
"""
from __future__ import annotations
from typing import Any
import psycopg
from psycopg.types.json import Jsonb
def get_watermark(conn: psycopg.Connection, source: str) -> int:
"""Return the last processed ``last_modified_t`` for *source* (0 if none)."""
row = conn.execute(
"SELECT last_modified_t FROM ingest_state WHERE source = %s", (source,)
).fetchone()
return int(row[0]) if row else 0
def set_watermark(
conn: psycopg.Connection,
source: str,
last_modified_t: int,
stats: dict[str, Any] | None = None,
) -> None:
"""Upsert the watermark and run metadata for *source*.
The watermark only ever moves forward: a lower ``last_modified_t`` is
ignored so an out-of-order or partial run cannot rewind progress.
"""
conn.execute(
"""
INSERT INTO ingest_state (source, last_modified_t, last_run_at, stats)
VALUES (%s, %s, now(), %s)
ON CONFLICT (source) DO UPDATE SET
last_modified_t = GREATEST(ingest_state.last_modified_t, EXCLUDED.last_modified_t),
last_run_at = now(),
stats = EXCLUDED.stats
""",
(source, int(last_modified_t), Jsonb(stats or {})),
)
+133
View File
@@ -0,0 +1,133 @@
"""Apply GS1 (or other authoritative) supplements to existing products.
A supplement only fills *gaps*: a field is written only when the product does
not already have a value. Each applied supplement records field-level provenance
in ``product_source`` and refreshes the product's quality score.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any
import psycopg
from psycopg.types.json import Jsonb
from opengoods import units
from opengoods.adapters.gs1 import GS1_HOMEPAGE, GS1_LICENSE, GS1_TRUST, SOURCE_NAME
from opengoods.etl.load import _ensure_brand, _normalize_brand, ensure_source_named
from opengoods.etl.quality import update_quality
def ensure_gs1_source(conn: psycopg.Connection) -> str:
"""Upsert the GS1 source row and return its id."""
return ensure_source_named(conn, SOURCE_NAME, GS1_HOMEPAGE, GS1_LICENSE, GS1_TRUST)
def _ensure_manufacturer(conn: psycopg.Connection, name: str | None) -> str | None:
if not name:
return None
row = conn.execute(
"""
INSERT INTO manufacturer (name, normalized_name)
VALUES (%s, %s)
ON CONFLICT (normalized_name) DO UPDATE SET name = manufacturer.name
RETURNING id
""",
(name, _normalize_brand(name)),
).fetchone()
return row[0]
def _net_content(rec: dict) -> tuple[Decimal, str, Decimal | None] | None:
raw_value = rec.get("net_content_value")
unit = rec.get("net_content_unit")
if raw_value is None or not unit:
return None
try:
value = Decimal(str(raw_value))
except (InvalidOperation, ValueError):
return None
try:
canonical = units.normalize(value, unit).canonical_value
except units.UnitError:
canonical = None
return value, unit, canonical
def apply_supplement(conn: psycopg.Connection, rec: dict[str, Any], source_id: str) -> list[str]:
"""Fill missing fields of the GTIN-matched product from ``rec``.
Returns the list of field names actually filled (empty if the product is
unknown or already complete for the supplied fields).
"""
gtin = rec.get("gtin")
if not gtin:
return []
prod = conn.execute(
"""
SELECT id, brand_id, manufacturer_id, gpc_brick_code, country_of_origin,
net_content_value
FROM product
WHERE gtin = %s AND status = 'active'
""",
(gtin,),
).fetchone()
if prod is None:
return []
product_id, brand_id, manufacturer_id, gpc, country, net_value = prod
sets: list[str] = []
params: list[Any] = []
filled: list[str] = []
if brand_id is None and rec.get("brand"):
new_brand_id = _ensure_brand(conn, rec["brand"])
if new_brand_id is not None:
sets.append("brand_id = %s")
params.append(new_brand_id)
filled.append("brand")
if manufacturer_id is None and rec.get("manufacturer"):
new_mfr_id = _ensure_manufacturer(conn, rec["manufacturer"])
if new_mfr_id is not None:
sets.append("manufacturer_id = %s")
params.append(new_mfr_id)
filled.append("manufacturer")
if gpc is None and rec.get("gpc_brick_code"):
sets.append("gpc_brick_code = %s")
params.append(rec["gpc_brick_code"])
filled.append("gpc_brick_code")
if country is None and rec.get("country_of_origin"):
sets.append("country_of_origin = %s")
params.append(rec["country_of_origin"])
filled.append("country_of_origin")
if net_value is None:
net = _net_content(rec)
if net is not None:
value, unit, canonical = net
sets += [
"net_content_value = %s",
"net_content_unit = %s",
"net_content_canonical = %s",
]
params += [value, unit, canonical]
filled.append("net_content")
if not filled:
return []
params.append(product_id)
conn.execute(f"UPDATE product SET {', '.join(sets)} WHERE id = %s", params)
conn.execute(
"""
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES (%s, %s, %s, %s, now(), %s)
""",
(product_id, source_id, GS1_HOMEPAGE, filled, Jsonb(rec)),
)
update_quality(conn, product_id)
return filled