Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion api/contracts/product_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
"title": "ProductDetailV1",
"type": "object",
"required": ["barcode", "productName"],
"description": "API contract for product detail responses. Implements three-layer architecture (DB011-aligned).",
"_design_notes": {
"scope": "Wire format sent from backend API to mobile",
"what_is_sent": "Core product fields, category + categories, nutriments + nutriments_normalized, images, tags {final, removed} (resolved/passthrough from mapper), metadata, enrichmentMetadata, dateAdded, lastUpdated when the backend attaches them.",
"what_is_not_sent": "productJson (cart snapshot blob) and the full enrichment scoring object — too large for typical product-detail payloads; mobile reconstructs cart JSON from wire fields. Server still uses enrichment.* in DB for ranking/indexes.",
"metadata_vs_enrichmentMetadata": "metadata tracks enrichment source/quality (general purpose); enrichmentMetadata tracks recommendation scoring (rec-specific). Split intentionally to keep concerns separate.",
"why_indexed_fields_missing": "enrichment.nutrition.* is not on wire; Firestore indexes on enrichment/tags are for backend pipelines, not for mobile-driven queries."
},
"properties": {
"barcode": { "type": "string", "description": "GTIN / barcode" },
"brand": { "type": ["string", "null"] },
Expand Down Expand Up @@ -64,7 +72,31 @@
"removed": { "type": "array", "items": { "type": "string" }, "default": [] }
}
},
"metadata": { "type": "object", "additionalProperties": true }
"dateAdded": {
"type": ["string", "null"]
},
"lastUpdated": {
"type": ["string", "null"]
},
"metadata": {
"type": "object",
"properties": {
"enrichmentSource": { "type": "string" },
"enrichmentTimestamp": { "type": ["string", "null"] },
"dataQualityScore": { "type": ["number", "null"] }
},
"additionalProperties": true
},
"enrichmentMetadata": {
"type": "object",
"properties": {
"recommendationScore": { "type": "number" },
"reasonTags": { "type": "array", "items": { "type": "string" } },
"similarityMetrics": { "type": "object" }
},
"additionalProperties": true
}

},
"additionalProperties": false
}
68 changes: 59 additions & 9 deletions database/seeding/schema_definition.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
"collection": "products",
"document_id": "barcode",
"description": "Firestore schema for enriched product documents (V1)",
"fields": {
"_design_notes": {
"db_vs_wire": "THREE-LAYER ARCHITECTURE (DB011-aligned): Fields are categorized as follows:",
"sent_on_wire": "Product detail API sends: core fields (barcode, productName, brand, categories, category, allergens, nutriscoreGrade, nutriments, nutriments_normalized, images, etc.), tags as resolved {final, removed} (from stored tag list or passthrough if already that shape), metadata, enrichmentMetadata, dateAdded, lastUpdated when present.",
"db_only_cart": "productJson: Stored in DB for cart snapshots (full product serialization); NOT sent on wire (too large); mobile reconstructs cart snapshot from wire fields.",
"db_only_wire_excluded": "enrichment (full nutrition scoring tree): Stored in DB for indexing and server-side recommendations; NOT sent on wire. Middleware may attach enrichmentMetadata separately.",
"index_strategy": "Indexes: (1) categories, allergens, nutriscoreGrade, brand (filtering/sorting, align with wire). (2) enrichment.nutrition.compositeScore, tags — backend/recommendation queries; not required for mobile to query Firestore directly."
},
"barcode": {
"type": "string",
"required": true,
Expand Down Expand Up @@ -99,6 +105,51 @@
"type": "string",
"required": false
},
"tags": {
"type": "object",
"required": false,
"description": "Product tags for categorization",
"properties": {
"final": { "type": "array", "items": "string" },
"removed": { "type": "array", "items": "string" }
}
},
"metadata": {
"type": "object",
"required": false,
"description": "Enrichment tracking",
"properties": {
"enrichmentSource": { "type": "string", "enum": ["backend", "ml", "manual", "openfoodfacts"] },
"enrichmentTimestamp": { "type": "string" },
"dataQualityScore": { "type": "number" }
}
},
"enrichmentMetadata": {
"type": "object",
"required": false,
"description": "Recommendation enrichment",
"properties": {
"recommendationScore": { "type": "number" },
"reasonTags": { "type": "array", "items": "string" },
"similarityMetrics": { "type": "object" }
}
},
"dateAdded": {
"type": "string",
"required": false,
"description": "ISO timestamp when product was added"
},
"lastUpdated": {
"type": "string",
"required": false,
"description": "ISO timestamp when product was last updated"
},
"productJson": {
"type": "object",
"required": false,
"description": "Full serialized product for cart snapshots",
"additionalProperties": true
},
"tracesFromIngredients": {
"type": "string",
"required": false
Expand Down Expand Up @@ -127,13 +178,12 @@
"required_subfields": ["compositeScore", "scores"]
}
}
}
},
},
"indexes": [
{ "fields": ["categories", "allergens"], "description": "Diet/allergen filtering" },
{ "fields": ["enrichment.nutrition.compositeScore"], "direction": "DESCENDING", "description": "Nutrition quality sorting" },
{ "fields": ["nutriscoreGrade"], "description": "Quick grade filter" },
{ "fields": ["enrichment.tags"], "description": "Array-contains for diet/lifestyle tags" },
{ "fields": ["brand", "nutriscoreGrade"], "description": "Brand + quality combo" }
{ "fields": ["categories", "allergens"], "description": "Diet/allergen filtering (sent on wire)" },
{ "fields": ["nutriscoreGrade"], "description": "Quick grade filter (sent on wire)" },
{ "fields": ["brand", "nutriscoreGrade"], "description": "Brand + quality combo (sent on wire)" },
{ "fields": ["enrichment.nutrition.compositeScore"], "direction": "DESCENDING", "description": "NOTE: enrichment object is DB-only (not sent on wire); reserved for server-side enrichment service" },
{ "fields": ["tags"], "description": "NOTE: tags object is DB-only (not sent on wire); used for product-level tracking, not recommendations" }
]
}
}
78 changes: 65 additions & 13 deletions mapping/map_enriched_to_product_detail.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, Any
from typing import Any, Dict, List
import logging

from database.clean_data.normalization.NutrientUnitNormalisation import normalize_nutriments_dict
Expand All @@ -22,8 +22,70 @@ def _safe_list(val):
return [val]


def _normalize_tag_name_list(items: Any) -> List[str]:
"""Coerce tag entries to string names (strings or {'tag': ...} dicts)."""
if not items:
return []
if not isinstance(items, list):
items = _safe_list(items)
out: List[str] = []
for x in items:
if isinstance(x, str) and x:
out.append(x)
elif isinstance(x, dict):
t = x.get("tag")
if t:
out.append(str(t))
return out


def _tags_to_wire(product: Dict[str, Any]) -> Dict[str, Any]:
"""
Build ProductDetail tags {final, removed}.
Accepts DB shape {final, removed} (strings or tag dicts) or a raw list for resolve_conflicts.
"""
raw = product.get("tags")
if raw is None:
return {"final": [], "removed": []}
if isinstance(raw, dict) and set(raw.keys()) & {"final", "removed"}:
return {
"final": _normalize_tag_name_list(raw.get("final")),
"removed": _normalize_tag_name_list(raw.get("removed")),
}
if isinstance(raw, list):
if not raw:
return {"final": [], "removed": []}
resolved = resolve_conflicts(raw)
final = [t.get("tag") for t in resolved.get("final_tags", []) if t and t.get("tag")]
removed = [t.get("tag") for t in resolved.get("removed", []) if t and t.get("tag")]
return {"final": final, "removed": removed}
return {"final": [], "removed": []}


def map_enriched_to_product_detail(product: Dict[str, Any]) -> Dict[str, Any]:
"""Map an enriched product record to ProductDetail V1 contract."""
"""
Map an enriched product record to ProductDetail V1 contract.

DESIGN NOTES (DB011-aligned, three-layer architecture):

MAP: barcode, productName, brand, categories, allergens, nutriments, nutriscoreGrade, etc.
→ From DB to API wire, with normalization for consistent format

DO NOT MAP (intentionally omitted on wire):
1. enrichmentMetadata, dateAdded, lastUpdated: Hydrated by backend after mapping when available.
2. productJson: DB-only cart snapshot; mobile reconstructs from wire fields.
3. enrichment object: Server-side nutrition scoring tree; not exposed on wire.

SENT ON WIRE (from this mapper):
- tags as {final, removed}: from stored tag list (resolve_conflicts) or passthrough from DB object shape.
- metadata with source="local-enriched": pipeline provenance.
- Core product fields: nutrition, allergens, categories, images, etc.

WHY SPLIT?
- Smaller API payloads for performance (enrichment/productJson too large for every request)
- Clean separation: product core (always sent) vs enrichment (backend-only)
- Allows backend to add enrichmentMetadata via middleware without mapper knowing about it
"""
out: Dict[str, Any] = {}

out["barcode"] = product.get("barcode")
Expand Down Expand Up @@ -79,17 +141,7 @@ def map_enriched_to_product_detail(product: Dict[str, Any]) -> Dict[str, Any]:
"variants": images.get("variants") or {},
}

# Tags: use resolver if tags present; otherwise empty lists
raw_tags = product.get("tags") or []
if raw_tags:
resolved = resolve_conflicts(raw_tags)
final = [t.get("tag") for t in resolved.get("final_tags", [])]
removed = [t.get("tag") for t in resolved.get("removed", [])]
else:
final = []
removed = []

out["tags"] = {"final": final, "removed": removed}
out["tags"] = _tags_to_wire(product)

out["metadata"] = {"source": "local-enriched"}

Expand Down
61 changes: 58 additions & 3 deletions mobile-app/types/Product.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,59 @@
* Product
*
* This is for the frontend after converting the backend product into frontend
* with a normalise function. This type is more strict
* with a normalise function. This type is more strict.
*
* DESIGN NOTES (DB011-aligned, three-layer architecture):
*
* FIELDS ON WIRE (from API / product_v1.json): Core product data plus:
* - category (primary), categories[], nutriments, nutriments_normalized
* - tags { final, removed } — resolved tag names (matches API contract)
* - metadata (enrichmentSource, enrichmentTimestamp, dataQualityScore)
* - enrichmentMetadata (recommendationScore, reasonTags, similarityMetrics)
* - dateAdded, lastUpdated when the backend sends them
*
* FIELDS NOT ON WIRE: productJson (local cart DB blob); full enrichment{} tree (server-only).
*
* METADATA DESIGN: ProductMetadata = source/quality; enrichmentMetadata = recommendation scoring.
*/

export type NutriScoreGrade = "A" | "B" | "C" | "D" | "E" | "UNKNOWN" | string;
export type NutrientLevel = "low" | "moderate" | "high" | "unknown";

export interface Images {
root: string; // e.g. https://images.openfoodfacts.org/images/products/930/069/500/8826
primary: string; // e.g. "front_en"
primary: string | null; // e.g. "front_en"
variants: Record<string, number>; // e.g. { front_en: 3, nutrition_en: 5 }
}

/** Normalised per-100g-style block from API (product_v1 nutriments_normalized). */
export interface NutrimentsNormalized {
energy_kj?: number | null;
energy_kcal?: number | null;
fat_g?: number | null;
saturated_fat_g?: number | null;
carbohydrates_g?: number | null;
sugars_g?: number | null;
proteins_g?: number | null;
salt_g?: number | null;
sodium_mg?: number | null;
fiber_g?: number | null;
[key: string]: number | null | undefined;
}

/** Wire shape for health/product tags after backend resolution. */
export interface ProductTagsWire {
final?: string[];
removed?: string[];
}

export interface ProductMetadata {
enrichmentSource?: "backend" | "ml" | "manual" | "openfoodfacts";
enrichmentTimestamp?: string;
dataQualityScore?: number;
[key: string]: any;
}

export interface Product {
barcode: string; // Unique ID
id?: string; // optional explicit id (may be set from backend or fallback)
Expand All @@ -22,6 +63,8 @@ export interface Product {
productName: string; // never undefined in UI
genericName: string | null;
brand: string | null; // keep as a single string; split later if needed
/** Primary category (first normalised category); from API when present */
category?: string | null;

// Ingredients / tags
ingredientsText: string | null;
Expand All @@ -38,6 +81,8 @@ export interface Product {

// Nutrition
nutriments: Record<string, number | string>; // supports hyphenated keys like "nova-group"
/** Present when normaliser maps API nutriments_normalized */
nutriments_normalized?: NutrimentsNormalized;
nutrientLevels: Record<"fat" | "salt" | "sugars" | "saturated-fat", NutrientLevel>;
nutriscoreGrade: NutriScoreGrade;

Expand All @@ -48,11 +93,21 @@ export interface Product {
servingQuantityUnit: string | null; // e.g. "g"

// Meta

completeness: number; // 0..1
metadata?: ProductMetadata;
enrichmentMetadata?: {
recommendationScore?: number;
reasonTags?: string[];
similarityMetrics?: Record<string, any>;
};
dateAdded?: string; // ISO string
lastUpdated?: string; // ISO string
completeness: number; // 0..1
imageURL?: Images; // legacy single-image object sometimes used by backend normalisers

/** Resolved tag names from API (product_v1 tags) */
tags?: ProductTagsWire;

// Images
images: Images;
}