diff --git a/api/contracts/product_v1.json b/api/contracts/product_v1.json index cd06848..f378d30 100644 --- a/api/contracts/product_v1.json +++ b/api/contracts/product_v1.json @@ -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"] }, @@ -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 } diff --git a/database/seeding/schema_definition.json b/database/seeding/schema_definition.json index eb08366..1780267 100644 --- a/database/seeding/schema_definition.json +++ b/database/seeding/schema_definition.json @@ -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, @@ -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 @@ -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" } ] -} \ No newline at end of file +} diff --git a/mapping/map_enriched_to_product_detail.py b/mapping/map_enriched_to_product_detail.py index f00afd5..6b9d7c8 100644 --- a/mapping/map_enriched_to_product_detail.py +++ b/mapping/map_enriched_to_product_detail.py @@ -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 @@ -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") @@ -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"} diff --git a/mobile-app/types/Product.d.ts b/mobile-app/types/Product.d.ts index 7e22cee..49c5f32 100644 --- a/mobile-app/types/Product.d.ts +++ b/mobile-app/types/Product.d.ts @@ -2,7 +2,20 @@ * 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; @@ -10,10 +23,38 @@ 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; // 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) @@ -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; @@ -38,6 +81,8 @@ export interface Product { // Nutrition nutriments: Record; // 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; @@ -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; + }; 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; }