Output:
Chunk ID: 6707c1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1723
Output:
    const last = medication.last_administered;
    const next = medication.next_planned;
    target.append(node("p", "v5-label", "Letzte tatsächlich verabreichte Medikation"));
    target.append(node("p", "", last ? `${last.name} · ${last.dose || "Dosis unbekannt"} · ${formatValue(last.date, "date")}` : "unbekannt"));
    target.append(node("p", "v5-label", "Nächste geplante Medikation"));
    target.append(node("p", "", next ? `${next.name} · ${next.dose || "Dosis unbekannt"} · ${formatValue(next.date, "date")} · geplant` : "unbekannt"));
    target.dataset.private = "true";
  }

  function renderNutrition() {
    const summary = q("#nutrition-summary");
    if (summary) {
      summary.replaceChildren();
      const point = latestPoint("nutrition.histamine");
      const card = node("article", "v5-card");
      card.append(node("h3", "v5-label", "Letzter vollständig klassifizierter Histamin-Score"));
      card.append(node("div", "v5-kpi", point ? formatValue(point.value) : "unbekannt"));
      card.append(node("div", "v5-meta", point ? formatValue(point.date, "date") : "Unvollständige Zuordnung bleibt unbekannt"));
      summary.append(card);
    }
    const review = q("#mapping-review");
    if (review) {
      review.replaceChildren();
      const rows = (bundle.nutrition && bundle.nutrition.mapping_review) || [];
      if (!rows.length) review.append(node("p", "v5-empty", "Keine offenen Mapping-Prüfungen"));
      rows.forEach((item) => {
        const row = node("article", "v5-card v5-card--quiet");
        row.append(node("strong", "", item.name));
        row.append(node("div", "v5-meta", `${item.occurrences} Dokumentation(en) · Status: nicht zugeordnet`));
        const form = node("form", "mapping-action-form");
        form.method = "post";
        form.action = "/health-actions/nutrition-mapping";
        [["csrf_token", q("#view-nutrition")?.dataset.mappingCsrf || ""], ["return_to", "v5"], ["queue_key", item.queue_key || ""], ["canonical_food", item.suggested_canonical_food || ""], ["sighi_score", item.suggested_score ?? "unknown"], ["traffic_light", item.suggested_traffic_light || "unknown"], ["mapping_scope", "canonical"], ["personal_tolerance", "unknown"]].forEach(([name, value]) => {
          const input = node("input");
          input.type = "hidden";
          input.name = name;
          input.value = String(value);
          form.append(input);
        });
        const submit = node("button", "", "Zuordnung vormerken");
        submit.type = "submit";
        submit.disabled = !item.queue_key;
        form.append(submit);
        row.append(form);
        review.append(row);
      });
    }
    const dayField = q("[data-nutrition-day-date]");
    if (dayField && !dayField.value) dayField.value = bundle.today || new Date().toISOString().slice(0, 10);
    const openDay = q("[data-nutrition-day-open]");
    if (openDay && openDay.dataset.bound !== "true") {
      openDay.dataset.bound = "true";
      openDay.addEventListener("click", loadNutritionDay);
    }
  }

  function nutritionGroup(title, rows) {
    const section = node("section", "nutrition-group");
    section.append(node("h4", "", title));
    if (!rows.length) section.append(node("p", "v5-empty", "unbekannt"));
    rows.forEach(([label, value, unit]) => section.append(node("div", "nutrition-row", `${label}: ${value === null || value === undefined ? "unbekannt" : `${formatValue(value)} ${unit || ""}`.trim()}`)));
    return section;
  }

  async function loadNutritionDay() {
    const status = q("[data-nutrition-day-status]");
    const target = q("[data-nutrition-day]");
    const day = q("[data-nutrition-day-date]")?.value || bundle.today;
    if (!target || !/^\d{4}-\d{2}-\d{2}$/.test(day || "")) return;
    if (status) status.textContent = "Ernährungstag wird geladen …";
    try {
      const data = await apiGet(`/api/v1/nutrition/day/${encodeURIComponent(day)}`);
      target.replaceChildren(
        nutritionGroup("Makros", [["Energie", data.macros?.kcal, "kcal"], ["Protein", data.macros?.protein_g, "g"], ["Kohlenhydrate", data.macros?.carb_g, "g"], ["Fett", data.macros?.fat_g, "g"]]),
        nutritionGroup("Nährstoffe", Object.entries(data.nutrients || {}).slice(0, 12).map(([key, item]) => [item.label || key, item.value, item.unit])),
        nutritionGroup("Mahlzeiten", (data.meals || []).map((meal) => [meal.label || meal.meal, meal.item_count, "Einträge"])),
      );
      if (status) status.textContent = data.coverage?.status === "complete" ? "Vollständig klassifizierter Ernährungstag." : "Unvollständige Werte bleiben unbekannt.";
    } catch (error) {
      if (status) status.textContent = `Ernährungstag nicht verfügbar (${error.message || "api_error"}).`;
    }
  }

  function renderDoctor() {
    renderMedication(q("#doctor-medication"));
    const target = q("#verified-labs");
    if (!target) return;
    target.replaceChildren();
    const labs = bundle.labs || [];
    if (!labs.length) target.append(node("p", "v5-empty", "Keine gegen das Original verifizierten Laborwerte verfügbar"));
    labs.forEach((lab) => {
      const row = node("article", "v5-card");
      row.dataset.private = "true";
      row.append(node("h3", "v5-label", lab.parameter));
      row.append(node("div", "v5-kpi", `${lab.value} ${lab.unit || ""}`.trim()));
      row.append(node("div", "v5-meta", `${formatValue(lab.date, "date")} · Referenz ${lab.reference_min ?? "–"} bis ${lab.reference_max ?? "–"} · gegen Original verifiziert`));
      target.append(row);
    });
  }

  function renderEventTrack(targetId, domainDays = [], options = {}) {
    const target = q(`#${targetId}`);
    if (!target) return;
    const positionDay = typeof options.positionDay === "function" ? options.positionDay : (event) => event.date;
    target.replaceChildren(node("strong", "", targetId === "explorer-event-track" ? "Explorer-Ereignisspur" : "Ereignisspur"));
    const events = periodData(bundle.events || []);
    const fallbackDays = Object.values(bundle.series || {}).flat().map((point) => point.date);
    const sortedDomain = (options.requireDomain ? domainDays : (domainDays.length ? domainDays : fallbackDays)).filter(Boolean).sort();
    if (!events.length || !sortedDomain.length) {
      target.append(node("p", "v5-meta", "Keine dokumentierten Ereignisse auf der sichtbaren Zeitachse."));
      return;
    }
    const start = sortedDomain[0];
    const end = sortedDomain[sortedDomain.length - 1];
    const rawSpan = daysBetween(start, end) || 0;
    const span = Math.max(1, rawSpan);
    const visibleEvents = events.filter((event) => {
      const positioned = positionDay(event);
      return positioned >= start && positioned <= end;
    });
    if (!visibleEvents.length) return target.append(node("p", "v5-empty", "Keine dokumentierten Ereignisse in der sichtbaren Zeitdomäne."));


Output:
Chunk ID: d51dbf
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2121
Output:

Tagesübersicht
Heute

Das Wichtigste aus den zuletzt dokumentierten Daten. Fehlende Werte bleiben unbekannt.

Momentaufnahme

Aufgaben

Medikation

<button type='button' class='primary-action mobile-capture-action' id='open-checkin' aria-describedby='capture-helper'>＋ Beschwerden erfassen</button>

Alle sieben Werte sind für einen vollständigen Tag erforderlich. Fehlende Angaben werden nicht als 0 gespeichert.

<dialog id='checkin-dialog' aria-labelledby='checkin-title'><form method='post' action='/health-actions/symptom-checkin'><input type='hidden' name='csrf_token' value='__CSRF_TOKEN__'><input type='hidden' name='return_to' value='v5'><input type='hidden' name='date' value='__CAPTURE_DATE__'>
Beschwerden erfassen

Erfassung für <time id='capture-date' datetime='__CAPTURE_DATE__'>__CAPTURE_DATE_LABEL__</time>

{symptom_fields}
Notiz, optional<textarea name='notes' maxlength='300'></textarea>
<button type='button' id='close-checkin'>Abbrechen</button><button type='submit'>Sicher vormerken</button>
</form></dialog>

Persönliche Trends
Cockpit

Dokumentierte Werte im persönlichen Verlauf; keine generische medizinische Bewertung.

Verlauf
Gesundheitsverlauf

Keine dokumentierten Beobachtungen im gewählten Zeitraum.

Zugängliche Datentabelle

Explorer
Gesundheitsverläufe

Einheitliche ECharts-Verlaufsansicht. Dokumentierte Werte ohne Diagnose-, Kausalitäts- oder Therapieaussage.

<button type='button' data-api-explorer-mode='raw' aria-pressed='true'>Rohwerte</button><button type='button' data-api-explorer-mode='baseline' aria-pressed='false'>Baseline · Index 100</button>

<button type='button' data-api-explorer-resolution='day' aria-pressed='true'>Täglich</button><button type='button' data-api-explorer-resolution='week' aria-pressed='false'>Wöchentlich</button>

      <button type='button' data-api-explorer-clear>Auswahl leeren</button>

Abkürzungen<button type='button' data-api-explorer-shortcut='Herz'>Herz/Kreislauf</button><button type='button' data-api-explorer-shortcut='Schlaf'>Schlaf/Erholung</button><button type='button' data-api-explorer-shortcut='Schritte'>Aktivität</button><button type='button' data-api-explorer-shortcut='Symptome'>Symptome</button>

Gesundheitswert hinzufügen
<input id='explorer-metric-search' type='search' data-api-explorer-search autocomplete='off'><button type='button' data-api-explorer-search-button>Suchen</button>

<button type='button' data-api-explorer-retry>Erneut versuchen</button>

    Zugängliche Datentabelle

  {prototype_markup}

{calendar_markup}

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Nur Anzeige – Bearbeitung folgt im Ernährungs-Sprint 6G. Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

{record_markup}

Daten & System

Technische Datenqualität und sekundäre Arbeitsansichten sind bewusst von den primären Gesundheitsansichten getrennt.

{'' if explorer_6c else ""}{echarts_scripts}{range_scripts}{explorer_scripts}{calendar_scripts}{record_scripts}{global_search_scripts}{prototype_scripts}
Dashboard v5 benötigt lokales JavaScript. Dashboard v4 bleibt als Fallback verfügbar.

"""


Output:
Chunk ID: 75450e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1304
Output:
            positive_hits TEXT,
            negative_hits TEXT,
            source TEXT,
            computed_at TEXT DEFAULT CURRENT_TIMESTAMP
        );

CREATE TABLE nutrition_daily_summary_v2 (
    datum TEXT PRIMARY KEY,
    kcal REAL DEFAULT 0,
    protein_g REAL DEFAULT 0,
    carb_g REAL DEFAULT 0,
    fat_g REAL DEFAULT 0,
    item_count INTEGER DEFAULT 0,
    nutrient_json TEXT,
    histamine_score REAL DEFAULT 0,
    histamine_max INTEGER DEFAULT 0,
    histamine_unknown_count INTEGER DEFAULT 0,
    histamine_label TEXT DEFAULT 'unknown',
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE nutrition_food_insights (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    canonical_food TEXT NOT NULL,
    insight_type TEXT NOT NULL CHECK(insight_type IN ('trigger_candidate','safe_candidate','insufficient_data')),
    days_seen INTEGER DEFAULT 0,
    avg_same_day_symptom REAL,
    avg_next_day_symptom REAL,
    avg_followup_symptom REAL,
    histamine_avg REAL,
    confidence TEXT DEFAULT 'low',
    notes TEXT,
    computed_at TEXT DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(canonical_food, insight_type)
);

CREATE TABLE nutrition_histamine_scores (
    item_id INTEGER PRIMARY KEY,
    canonical_food TEXT,
    sighi_score INTEGER,
    traffic_light TEXT NOT NULL,
    tags TEXT,
    confidence TEXT DEFAULT 'low',
    reason TEXT,
    scored_at TEXT DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (item_id) REFERENCES nutrition_items(id) ON DELETE CASCADE
);

CREATE TABLE nutrition_item_nutrients (
    item_id INTEGER NOT NULL,
    nutrient_key TEXT NOT NULL,
    value REAL,
    unit TEXT,
    raw_value TEXT,
    PRIMARY KEY (item_id, nutrient_key),
    FOREIGN KEY (item_id) REFERENCES nutrition_items(id) ON DELETE CASCADE
);

CREATE TABLE nutrition_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source TEXT NOT NULL DEFAULT 'yazio_api',
    source_item_id TEXT,
    source_product_id TEXT,
    datum TEXT NOT NULL,
    meal TEXT NOT NULL,
    name TEXT NOT NULL,
    brand TEXT,
    amount REAL,
    amount_unit TEXT DEFAULT 'g',
    serving TEXT,
    serving_quantity REAL,
    kcal REAL,
    protein_g REAL,
    carb_g REAL,
    fat_g REAL,
    raw_item_json TEXT,
    raw_product_json TEXT,
    item_hash TEXT NOT NULL UNIQUE,
    imported_at TEXT DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE nutrition_meal_summary (
    datum TEXT NOT NULL,
    meal TEXT NOT NULL,
    kcal REAL DEFAULT 0,
    protein_g REAL DEFAULT 0,
    carb_g REAL DEFAULT 0,
    fat_g REAL DEFAULT 0,
    item_count INTEGER DEFAULT 0,
    nutrient_json TEXT,
    histamine_score REAL DEFAULT 0,
    histamine_label TEXT DEFAULT 'unknown',
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (datum, meal)
);

CREATE TABLE nutrition_review_queue (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    normalized_name TEXT NOT NULL UNIQUE,
    example_name TEXT NOT NULL,
    occurrence_count INTEGER DEFAULT 0,
    first_seen TEXT,
    last_seen TEXT,
    suggested_canonical_food TEXT,
    suggested_score INTEGER,
    reason TEXT,
    status TEXT DEFAULT 'open' CHECK(status IN ('open','mapped','ignored')),
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE nutrition_treatment_phase_summary (
        phase TEXT PRIMARY KEY,
        start_date TEXT,
        end_date TEXT,
        nutrition_days INTEGER DEFAULT 0,
        avg_histamine_score REAL,
        avg_symptom_score REAL,
        avg_kcal REAL,
        avg_protein_g REAL,
        notes TEXT,
        computed_at TEXT DEFAULT CURRENT_TIMESTAMP
    );

CREATE TABLE personal_food_tolerance (
    canonical_food TEXT PRIMARY KEY,
    personal_status TEXT NOT NULL DEFAULT 'unknown' CHECK(personal_status IN ('safe','problematic','unclear','unknown')),
    evidence_level TEXT DEFAULT 'low' CHECK(evidence_level IN ('low','medium','high')),
    notes TEXT,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE nutrition_mapping_action_log (
    action_id TEXT PRIMARY KEY,
    action_hash TEXT NOT NULL,
    queue_key TEXT NOT NULL,
    applied_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE nutrition_mapping_provenance (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    action_id TEXT NOT NULL,
    normalized_name TEXT NOT NULL,
    canonical_food TEXT,
    sighi_score INTEGER,
    traffic_light TEXT NOT NULL,
    mapping_scope TEXT NOT NULL,
    affected_day TEXT NOT NULL,
    applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(action_id, affected_day)
);

CREATE TABLE nutrition_composite_product_review (
    normalized_name TEXT PRIMARY KEY,
    canonical_food TEXT,
    status TEXT NOT NULL DEFAULT 'needs_ingredient_review' CHECK(status IN ('needs_ingredient_review','reviewed')),
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE multimodal_correlation_results (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    predictor TEXT NOT NULL,
    target TEXT NOT NULL,
    lag_days INTEGER NOT NULL,
    medication_phase TEXT NOT NULL,
    n INTEGER NOT NULL,
    eligible_target_days INTEGER NOT NULL,
    expected_target_days INTEGER NOT NULL DEFAULT 0,
    target_coverage REAL NOT NULL DEFAULT 0,
    missing_pairs INTEGER NOT NULL,
    rho REAL,


I’m about to edit the worker, server form validation, V5 contracts/read API, fixture, schema, UI, tests, and ADR. The main behavioral change is that “classification” becomes a neutral mapping score with explicit provenance, and composite/ignore decisions become first-class outcomes instead of implicit side effects.


Input:
*** Begin Patch
*** Update File: scripts/health/health_dashboard_action_worker.py
@@
 ALLOWED_V5_PROFILES = frozenset({V5_PROFILE_DEFAULT, V5_PROFILE_HEALTH_RECORD_6E})
+MAPPING_DECISIONS = frozenset({"assign", "composite", "ignore"})
+MAPPING_CONFIDENCE = frozenset({"low", "medium", "high"})
+MAPPING_METHODS = frozenset({"sighi_reference", "ingredient_label", "manual_review", "local_alias"})
+HISTAMINE_DEFINITION_VERSION = "sighi_mapping_load_v2"
@@
 def validate_mapping_payload(payload: dict[str, Any]) -> dict[str, Any]:
-    if set(payload) != {
+    required = {
         "version",
         "action",
+        "decision",
         "queue_key",
+        "alias",
         "canonical_food",
         "sighi_score",
-        "traffic_light",
-        "mapping_scope",
-        "personal_tolerance",
-    }:
+        "confidence",
+        "mapping_method",
+        "source_label",
+        "source_version",
+        "note",
+        "ingredient_review_required",
+    }
+    optional = {"personal_tolerance"}
+    if not required <= set(payload) <= required | optional:
         raise ValueError("invalid mapping payload shape")
     if payload["version"] != 1 or payload["action"] != "nutrition_mapping":
         raise ValueError("unsupported action")
+    decision = payload["decision"]
     queue_key = payload["queue_key"]
+    alias = " ".join(str(payload["alias"] or "").split())
     canonical = " ".join(str(payload["canonical_food"] or "").split())
-    score = payload["sighi_score"]
-    light = payload["traffic_light"]
-    scope = payload["mapping_scope"]
-    tolerance = payload["personal_tolerance"]
+    score = payload["sighi_score"]
+    confidence = payload["confidence"]
+    method = payload["mapping_method"]
+    source_label = " ".join(str(payload["source_label"] or "").split())
+    source_version = " ".join(str(payload["source_version"] or "").split())
+    note = " ".join(str(payload["note"] or "").split())
+    ingredient_review = payload["ingredient_review_required"]
+    tolerance = payload.get("personal_tolerance")
     if not isinstance(queue_key, str) or not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
         raise ValueError("invalid queue key")
-    if not isinstance(canonical, str) or len(canonical) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical, re.I):
-        raise ValueError("invalid canonical food")
-    if score is not None and (type(score) is not int or score not in range(4)):
-        raise ValueError("invalid score")
-    if light not in {"green", "yellow", "orange", "red", "unknown"} or scope != "canonical" or tolerance not in {"unknown", "safe", "problematic", "unclear"}:
+    if decision not in MAPPING_DECISIONS or confidence not in MAPPING_CONFIDENCE or method not in MAPPING_METHODS:
         raise ValueError("invalid mapping value")
-    if score is None and light != "unknown":
-        raise ValueError("unknown score must keep unknown light")
-    if score is not None and not canonical:
-        raise ValueError("canonical food required")
+    for field_name, text, maximum in (
+        ("alias", alias, 160),
+        ("canonical food", canonical, 120),
+        ("source label", source_label, 80),
+        ("source version", source_version, 40),
+        ("note", note, 300),
+    ):
+        if len(text) > maximum or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", text, re.I):
+            raise ValueError(f"invalid {field_name}")
+    if not alias or not source_label or not source_version:
+        raise ValueError("required mapping documentation missing")
+    if score != "unknown" and (type(score) is not int or score not in range(4)):
+        raise ValueError("invalid score")
+    if type(ingredient_review) is not bool:
+        raise ValueError("invalid ingredient review flag")
+    if tolerance is not None and (not isinstance(tolerance, str) or len(tolerance) > 160 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", tolerance, re.I)):
+        raise ValueError("invalid personal tolerance")
+    if decision == "assign":
+        if not canonical or score == "unknown" or ingredient_review:
+            raise ValueError("assign requires canonical score without ingredient review")
+    elif decision == "composite":
+        if score != "unknown" or not ingredient_review:
+            raise ValueError("composite must stay unclassified for ingredient review")
+    elif decision == "ignore":
+        if score != "unknown" or canonical or ingredient_review:
+            raise ValueError("ignore must not classify")
     return {
         "version": 1,
         "action": "nutrition_mapping",
+        "decision": decision,
         "queue_key": queue_key,
+        "alias": alias,
         "canonical_food": canonical,
-        "sighi_score": score,
-        "traffic_light": light,
-        "mapping_scope": scope,
-        "personal_tolerance": tolerance,
+        "sighi_score": score,
+        "confidence": confidence,
+        "mapping_method": method,
+        "source_label": source_label,
+        "source_version": source_version,
+        "note": note,
+        "ingredient_review_required": ingredient_review,
+        "personal_tolerance": tolerance,
     }
@@
 def _score_label(max_score: int, unknown: int, load: float) -> str:
-    if max_score >= 3:
-        return "red"
-    if max_score >= 2 or load >= 4:
-        return "orange"
-    if max_score == 1 or load >= 1:
-        return "yellow"
     if unknown:
         return "unknown"
-    return "green"
+    return "classified"
+
+
+def _public_numeric(value: Any) -> float | None:
+    try:
+        numeric = float(value)
+    except (TypeError, ValueError):
+        return None
+    return numeric if numeric == numeric and numeric not in {float("inf"), float("-inf")} else None
+
+
+NUTRIENT_ALLOWLIST = {
+    "energy.energy": "kcal",
+    "nutrient.protein": "g",
+    "nutrient.carb": "g",
+    "nutrient.fat": "g",
+    "nutrient.fiber": "g",
+    "nutrient.sugar": "g",
+    "nutrient.saturated_fat": "g",
+    "nutrient.salt": "g",
+    "nutrient.sodium": "mg",
+}
+
+
+def _nutrient_totals(connection: sqlite3.Connection, item_ids: list[int]) -> str:
+    if not item_ids:
+        return "{}"
+    placeholders = ",".join("?" for _ in item_ids)
+    totals: dict[str, float] = {}
+    for row in connection.execute(
+        f"""SELECT nutrient_key,value,unit FROM nutrition_item_nutrients
+            WHERE item_id IN ({placeholders})""",
+        item_ids,
+    ):
+        key = str(row["nutrient_key"] or "")
+        unit = str(row["unit"] or "")
+        value = _public_numeric(row["value"])
+        if key not in NUTRIENT_ALLOWLIST or unit != NUTRIENT_ALLOWLIST[key] or value is None:
+            continue
+        totals[key] = totals.get(key, 0.0) + value
+    return json.dumps({key: round(value, 3) for key, value in sorted(totals.items())}, separators=(",", ":"))
@@
         CREATE TABLE IF NOT EXISTS nutrition_mapping_action_log (
             action_id TEXT PRIMARY KEY,
             action_hash TEXT NOT NULL,
             queue_key TEXT NOT NULL,
+            decision TEXT,
+            alias TEXT,
+            canonical_food TEXT,
+            sighi_score INTEGER,
+            confidence TEXT,
+            mapping_method TEXT,
+            source_label TEXT,
+            source_version TEXT,
+            note TEXT,
+            ingredient_review_required INTEGER,
+            personal_tolerance TEXT,
+            definition_version TEXT,
             applied_at TEXT DEFAULT CURRENT_TIMESTAMP
         );
         CREATE TABLE IF NOT EXISTS nutrition_mapping_provenance (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             action_id TEXT NOT NULL,
             normalized_name TEXT NOT NULL,
+            decision TEXT,
+            alias TEXT,
             canonical_food TEXT,
             sighi_score INTEGER,
-            traffic_light TEXT NOT NULL,
-            mapping_scope TEXT NOT NULL,
+            confidence TEXT,
+            mapping_method TEXT,
+            source_label TEXT,
+            source_version TEXT,
+            note TEXT,
+            ingredient_review_required INTEGER,
+            personal_tolerance TEXT,
+            definition_version TEXT,
             affected_day TEXT NOT NULL,
             applied_at TEXT DEFAULT CURRENT_TIMESTAMP,
             UNIQUE(action_id, affected_day)
@@
         );
         """
     )
+    for table, columns in {
+        "nutrition_mapping_action_log": {
+            "decision": "TEXT", "alias": "TEXT", "canonical_food": "TEXT", "sighi_score": "INTEGER",
+            "confidence": "TEXT", "mapping_method": "TEXT", "source_label": "TEXT", "source_version": "TEXT",
+            "note": "TEXT", "ingredient_review_required": "INTEGER", "personal_tolerance": "TEXT",
+            "definition_version": "TEXT",
+        },
+        "nutrition_mapping_provenance": {
+            "decision": "TEXT", "alias": "TEXT", "confidence": "TEXT", "mapping_method": "TEXT",
+            "source_label": "TEXT", "source_version": "TEXT", "note": "TEXT",
+            "ingredient_review_required": "INTEGER", "personal_tolerance": "TEXT",
+            "definition_version": "TEXT",
+        },
+    }.items():
+        existing = {row["name"] for row in connection.execute(f"PRAGMA table_info({table})")}
+        for name, declaration in columns.items():
+            if name not in existing:
+                connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}")
@@
-            """SELECT i.meal,i.kcal,i.protein_g,i.carb_g,i.fat_g,h.sighi_score
+            """SELECT i.id,i.meal,i.kcal,i.protein_g,i.carb_g,i.fat_g,h.sighi_score
                FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
                WHERE i.datum=?""",
@@
-    totals = {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []}
+    totals = {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": [], "missing": set(), "ids": []}
     by_meal: dict[str, dict[str, Any]] = {}
     for row in rows:
-        meal = row["meal"] or "unknown"
-        bucket = by_meal.setdefault(meal, {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []})
+        meal = row["meal"] if row["meal"] in {"breakfast", "lunch", "dinner", "snack"} else "unassigned"
+        bucket = by_meal.setdefault(meal, {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": [], "missing": set(), "ids": []})
         for target in (bucket, totals):
             target["items"] += 1
-            target["kcal"] += float(row["kcal"] or 0)
-            target["protein"] += float(row["protein_g"] or 0)
-            target["carb"] += float(row["carb_g"] or 0)
-            target["fat"] += float(row["fat_g"] or 0)
+            target["ids"].append(int(row["id"]))
+            for source, dest in (("kcal", "kcal"), ("protein_g", "protein"), ("carb_g", "carb"), ("fat_g", "fat")):
+                if row[source] is None:
+                    target["missing"].add(dest)
+                else:
+                    target[dest] += float(row[source])
             if row["sighi_score"] is None:
                 target["unknown"] += 1
             else:
@@
-        connection.execute(
+        values = {key: None if key in bucket["missing"] else round(float(bucket[key]), 1) for key in ("kcal", "protein", "carb", "fat")}
+        connection.execute(
             """INSERT OR REPLACE INTO nutrition_meal_summary
                (datum,meal,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,histamine_score,histamine_label,updated_at)
                VALUES(?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
-            (day, meal, round(bucket["kcal"], 1), round(bucket["protein"], 1), round(bucket["carb"], 1), round(bucket["fat"], 1), int(bucket["items"]), "{}", round(bucket["load"], 1), label),
+            (day, meal, values["kcal"], values["protein"], values["carb"], values["fat"], int(bucket["items"]), _nutrient_totals(connection, bucket["ids"]), round(bucket["load"], 1) if not bucket["unknown"] else None, label),
         )
@@
-        (day, round(totals["kcal"], 1), round(totals["protein"], 1), round(totals["carb"], 1), round(totals["fat"], 1), int(totals["items"]), "{}", round(totals["load"], 1), max_score, int(totals["unknown"]), label),
+        (
+            day,
+            None if "kcal" in totals["missing"] else round(float(totals["kcal"]), 1),
+            None if "protein" in totals["missing"] else round(float(totals["protein"]), 1),
+            None if "carb" in totals["missing"] else round(float(totals["carb"]), 1),
+            None if "fat" in totals["missing"] else round(float(totals["fat"]), 1),
+            int(totals["items"]),
+            _nutrient_totals(connection, totals["ids"]),
+            round(totals["load"], 1) if not totals["unknown"] else None,
+            max_score,
+            int(totals["unknown"]),
+            label,
+        ),
     )
@@
-            matched = [row for row in queue if _slug(str(row["normalized_name"])) == payload["queue_key"]]
+            matched = [row for row in queue if _slug(str(row["normalized_name"])) == payload["queue_key"]]
             if len(matched) != 1:
                 raise RuntimeError("mapping queue item not found")
             normalized = str(matched[0]["normalized_name"])
             canonical = payload["canonical_food"]
-            score = payload["sighi_score"]
-            light = payload["traffic_light"]
-            affected = sorted({row["datum"] for row in connection.execute("SELECT datum FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)) if row["datum"]})
+            score = None if payload["sighi_score"] == "unknown" else payload["sighi_score"]
+            item_rows = [
+                row for row in connection.execute("SELECT id,datum,name,source_product_id FROM nutrition_items")
+                if _slug(str(row["name"] or row["source_product_id"] or "")) == payload["queue_key"]
+                or _slug(str(row["source_product_id"] or row["name"] or "")) == payload["queue_key"]
+            ]
+            affected = sorted({row["datum"] for row in item_rows if row["datum"]})
             if len(affected) > 366:
                 raise RuntimeError("affected day limit exceeded")
-            if re.search(r"\+|,|;|\bmit\b|\bund\b", str(matched[0]["example_name"]), re.I):
+            if payload["decision"] == "composite":
                 connection.execute(
                     """INSERT OR REPLACE INTO nutrition_composite_product_review
                        (normalized_name,canonical_food,status,updated_at)
@@
                     (normalized, canonical),
                 )
-                score = None
-                light = "unknown"
-            for row in connection.execute("SELECT id FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)):
-                connection.execute(
-                    """INSERT OR REPLACE INTO nutrition_histamine_scores
-                       (item_id,canonical_food,sighi_score,traffic_light,tags,confidence,reason,scored_at)
-                       VALUES(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
-                    (row["id"], canonical or None, score, light, "worker_mapping_v1", "medium", "local validated mapping action"),
-                )
-            connection.execute("UPDATE nutrition_review_queue SET status='mapped',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
-            if payload["personal_tolerance"] != "unknown" and canonical:
-                connection.execute(
-                    """INSERT INTO personal_food_tolerance(canonical_food,personal_status,evidence_level,notes,updated_at)
-                       VALUES(?,?, 'low', 'separate personal tolerance label from mapping action', CURRENT_TIMESTAMP)
-                       ON CONFLICT(canonical_food) DO UPDATE SET personal_status=excluded.personal_status,updated_at=CURRENT_TIMESTAMP""",
-                    (canonical, payload["personal_tolerance"]),
-                )
+            elif payload["decision"] == "assign":
+                for row in item_rows:
+                    connection.execute(
+                        """INSERT OR REPLACE INTO nutrition_histamine_scores
+                           (item_id,canonical_food,sighi_score,traffic_light,tags,confidence,reason,scored_at)
+                           VALUES(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+                        (row["id"], canonical, score, "classified", payload["mapping_method"], payload["confidence"], payload["note"] or "local validated mapping action"),
+                    )
+                connection.execute("UPDATE nutrition_review_queue SET status='mapped',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
+            else:
+                connection.execute("UPDATE nutrition_review_queue SET status='ignored',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
             for day in affected:
-                recompute_nutrition_day(connection, str(day))
+                if payload["decision"] == "assign":
+                    recompute_nutrition_day(connection, str(day))
                 connection.execute(
                     """INSERT OR IGNORE INTO nutrition_mapping_provenance
-                       (action_id,normalized_name,canonical_food,sighi_score,traffic_light,mapping_scope,affected_day)
-                       VALUES(?,?,?,?,?,?,?)""",
-                    (action_id, normalized, canonical or None, score, light, payload["mapping_scope"], str(day)),
+                       (action_id,normalized_name,decision,alias,canonical_food,sighi_score,confidence,
+                        mapping_method,source_label,source_version,note,ingredient_review_required,
+                        personal_tolerance,definition_version,affected_day)
+                       VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+                    (
+                        action_id, normalized, payload["decision"], payload["alias"], canonical or None,
+                        score, payload["confidence"], payload["mapping_method"], payload["source_label"],
+                        payload["source_version"], payload["note"], int(payload["ingredient_review_required"]),
+                        payload.get("personal_tolerance"), HISTAMINE_DEFINITION_VERSION, str(day),
+                    ),
                 )
             connection.execute(
-                "INSERT INTO nutrition_mapping_action_log(action_id,action_hash,queue_key) VALUES(?,?,?)",
-                (action_id, action_hash, payload["queue_key"]),
+                """INSERT INTO nutrition_mapping_action_log
+                   (action_id,action_hash,queue_key,decision,alias,canonical_food,sighi_score,confidence,
+                    mapping_method,source_label,source_version,note,ingredient_review_required,
+                    personal_tolerance,definition_version)
+                   VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+                (
+                    action_id, action_hash, payload["queue_key"], payload["decision"], payload["alias"],
+                    canonical or None, score, payload["confidence"], payload["mapping_method"],
+                    payload["source_label"], payload["source_version"], payload["note"],
+                    int(payload["ingredient_review_required"]), payload.get("personal_tolerance"),
+                    HISTAMINE_DEFINITION_VERSION,
+                ),
             )
@@
         if DASHBOARD_V5_PROFILE == V5_PROFILE_HEALTH_RECORD_6E:
-            v5_command.append("--health-record-6e")
+            v5_command.extend(["--health-record-6e", "--explorer-6c", "--calendar-day-6d"])
*** End Patch


Success. Updated the following files:
M scripts/health/health_dashboard_action_worker.py

@@ -52,2 +52,6 @@
 ALLOWED_V5_PROFILES = frozenset({V5_PROFILE_DEFAULT, V5_PROFILE_HEALTH_RECORD_6E})
+MAPPING_DECISIONS = frozenset({"assign", "composite", "ignore"})
+MAPPING_CONFIDENCE = frozenset({"low", "medium", "high"})
+MAPPING_METHODS = frozenset({"sighi_reference", "ingredient_label", "manual_review", "local_alias"})
+HISTAMINE_DEFINITION_VERSION = "sighi_mapping_load_v2"

@@ -107,12 +111,19 @@
 def validate_mapping_payload(payload: dict[str, Any]) -> dict[str, Any]:
-    if set(payload) != {
+    required = {
         "version",
         "action",
+        "decision",
         "queue_key",
+        "alias",
         "canonical_food",
         "sighi_score",
-        "traffic_light",
-        "mapping_scope",
-        "personal_tolerance",
-    }:
+        "confidence",
+        "mapping_method",
+        "source_label",
+        "source_version",
+        "note",
+        "ingredient_review_required",
+    }
+    optional = {"personal_tolerance"}
+    if not required <= set(payload) <= required | optional:
         raise ValueError("invalid mapping payload shape")
@@ -120,20 +131,44 @@
         raise ValueError("unsupported action")
+    decision = payload["decision"]
     queue_key = payload["queue_key"]
+    alias = " ".join(str(payload["alias"] or "").split())
     canonical = " ".join(str(payload["canonical_food"] or "").split())
     score = payload["sighi_score"]
-    light = payload["traffic_light"]
-    scope = payload["mapping_scope"]
-    tolerance = payload["personal_tolerance"]
+    confidence = payload["confidence"]
+    method = payload["mapping_method"]
+    source_label = " ".join(str(payload["source_label"] or "").split())
+    source_version = " ".join(str(payload["source_version"] or "").split())
+    note = " ".join(str(payload["note"] or "").split())
+    ingredient_review = payload["ingredient_review_required"]
+    tolerance = payload.get("personal_tolerance")
     if not isinstance(queue_key, str) or not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
         raise ValueError("invalid queue key")
-    if not isinstance(canonical, str) or len(canonical) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical, re.I):
-        raise ValueError("invalid canonical food")
-    if score is not None and (type(score) is not int or score not in range(4)):
+    if decision not in MAPPING_DECISIONS or confidence not in MAPPING_CONFIDENCE or method not in MAPPING_METHODS:
+        raise ValueError("invalid mapping value")
+    for field_name, text, maximum in (
+        ("alias", alias, 160),
+        ("canonical food", canonical, 120),
+        ("source label", source_label, 80),
+        ("source version", source_version, 40),
+        ("note", note, 300),
+    ):
+        if len(text) > maximum or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", text, re.I):
+            raise ValueError(f"invalid {field_name}")
+    if not alias or not source_label or not source_version:
+        raise ValueError("required mapping documentation missing")
+    if score != "unknown" and (type(score) is not int or score not in range(4)):
         raise ValueError("invalid score")
-    if light not in {"green", "yellow", "orange", "red", "unknown"} or scope != "canonical" or tolerance not in {"unknown", "safe", "problematic", "unclear"}:
-        raise ValueError("invalid mapping value")
-    if score is None and light != "unknown":
-        raise ValueError("unknown score must keep unknown light")
-    if score is not None and not canonical:
-        raise ValueError("canonical food required")
+    if type(ingredient_review) is not bool:
+        raise ValueError("invalid ingredient review flag")
+    if tolerance is not None and (not isinstance(tolerance, str) or len(tolerance) > 160 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", tolerance, re.I)):
+        raise ValueError("invalid personal tolerance")
+    if decision == "assign":
+        if not canonical or score == "unknown" or ingredient_review:
+            raise ValueError("assign requires canonical score without ingredient review")
+    elif decision == "composite":
+        if score != "unknown" or not ingredient_review:
+            raise ValueError("composite must stay unclassified for ingredient review")
+    elif decision == "ignore":
+        if score != "unknown" or canonical or ingredient_review:
+            raise ValueError("ignore must not classify")
     return {
@@ -141,7 +176,13 @@
         "action": "nutrition_mapping",
+        "decision": decision,
         "queue_key": queue_key,
+        "alias": alias,
         "canonical_food": canonical,
         "sighi_score": score,
-        "traffic_light": light,
-        "mapping_scope": scope,
+        "confidence": confidence,
+        "mapping_method": method,
+        "source_label": source_label,
+        "source_version": source_version,
+        "note": note,
+        "ingredient_review_required": ingredient_review,
         "personal_tolerance": tolerance,
@@ -234,13 +275,47 @@
 def _score_label(max_score: int, unknown: int, load: float) -> str:
-    if max_score >= 3:
-        return "red"
-    if max_score >= 2 or load >= 4:
-        return "orange"
-    if max_score == 1 or load >= 1:
-        return "yellow"
     if unknown:
         return "unknown"
-    return "green"
+    return "classified"

+def _public_numeric(value: Any) -> float | None:
+    try:
+        numeric = float(value)
+    except (TypeError, ValueError):
+        return None
+    return numeric if numeric == numeric and numeric not in {float("inf"), float("-inf")} else None
+
+
+NUTRIENT_ALLOWLIST = {
+    "energy.energy": "kcal",
+    "nutrient.protein": "g",
+    "nutrient.carb": "g",
+    "nutrient.fat": "g",
+    "nutrient.fiber": "g",
+    "nutrient.sugar": "g",
+    "nutrient.saturated_fat": "g",
+    "nutrient.salt": "g",
+    "nutrient.sodium": "mg",
+}
+
+
+def _nutrient_totals(connection: sqlite3.Connection, item_ids: list[int]) -> str:
+    if not item_ids:
+        return "{}"
+    placeholders = ",".join("?" for _ in item_ids)
+    totals: dict[str, float] = {}
+    for row in connection.execute(
+        f"""SELECT nutrient_key,value,unit FROM nutrition_item_nutrients
+            WHERE item_id IN ({placeholders})""",
+        item_ids,
+    ):
+        key = str(row["nutrient_key"] or "")
+        unit = str(row["unit"] or "")
+        value = _public_numeric(row["value"])
+        if key not in NUTRIENT_ALLOWLIST or unit != NUTRIENT_ALLOWLIST[key] or value is None:
+            continue
+        totals[key] = totals.get(key, 0.0) + value
+    return json.dumps({key: round(value, 3) for key, value in sorted(totals.items())}, separators=(",", ":"))
+
+
 def ensure_nutrition_mapping_schema(connection: sqlite3.Connection) -> None:
@@ -252,2 +327,14 @@
             queue_key TEXT NOT NULL,
+            decision TEXT,
+            alias TEXT,
+            canonical_food TEXT,
+            sighi_score INTEGER,
+            confidence TEXT,
+            mapping_method TEXT,
+            source_label TEXT,
+            source_version TEXT,
+            note TEXT,
+            ingredient_review_required INTEGER,
+            personal_tolerance TEXT,
+            definition_version TEXT,
             applied_at TEXT DEFAULT CURRENT_TIMESTAMP
@@ -258,6 +345,14 @@
             normalized_name TEXT NOT NULL,
+            decision TEXT,
+            alias TEXT,
             canonical_food TEXT,
             sighi_score INTEGER,
-            traffic_light TEXT NOT NULL,
-            mapping_scope TEXT NOT NULL,
+            confidence TEXT,
+            mapping_method TEXT,
+            source_label TEXT,
+            source_version TEXT,
+            note TEXT,
+            ingredient_review_required INTEGER,
+            personal_tolerance TEXT,
+            definition_version TEXT,
             affected_day TEXT NOT NULL,
@@ -274,2 +369,20 @@
     )
+    for table, columns in {
+        "nutrition_mapping_action_log": {
+            "decision": "TEXT", "alias": "TEXT", "canonical_food": "TEXT", "sighi_score": "INTEGER",
+            "confidence": "TEXT", "mapping_method": "TEXT", "source_label": "TEXT", "source_version": "TEXT",
+            "note": "TEXT", "ingredient_review_required": "INTEGER", "personal_tolerance": "TEXT",
+            "definition_version": "TEXT",
+        },
+        "nutrition_mapping_provenance": {
+            "decision": "TEXT", "alias": "TEXT", "confidence": "TEXT", "mapping_method": "TEXT",
+            "source_label": "TEXT", "source_version": "TEXT", "note": "TEXT",
+            "ingredient_review_required": "INTEGER", "personal_tolerance": "TEXT",
+            "definition_version": "TEXT",
+        },
+    }.items():
+        existing = {row["name"] for row in connection.execute(f"PRAGMA table_info({table})")}
+        for name, declaration in columns.items():
+            if name not in existing:
+                connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {declaration}")

@@ -279,3 +392,3 @@
         connection.execute(
-            """SELECT i.meal,i.kcal,i.protein_g,i.carb_g,i.fat_g,h.sighi_score
+            """SELECT i.id,i.meal,i.kcal,i.protein_g,i.carb_g,i.fat_g,h.sighi_score
                FROM nutrition_items i LEFT JOIN nutrition_histamine_scores h ON h.item_id=i.id
@@ -289,13 +402,15 @@
         return
-    totals = {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []}
+    totals = {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": [], "missing": set(), "ids": []}
     by_meal: dict[str, dict[str, Any]] = {}
     for row in rows:
-        meal = row["meal"] or "unknown"
-        bucket = by_meal.setdefault(meal, {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": []})
+        meal = row["meal"] if row["meal"] in {"breakfast", "lunch", "dinner", "snack"} else "unassigned"
+        bucket = by_meal.setdefault(meal, {"kcal": 0.0, "protein": 0.0, "carb": 0.0, "fat": 0.0, "items": 0, "unknown": 0, "load": 0.0, "scores": [], "missing": set(), "ids": []})
         for target in (bucket, totals):
             target["items"] += 1
-            target["kcal"] += float(row["kcal"] or 0)
-            target["protein"] += float(row["protein_g"] or 0)
-            target["carb"] += float(row["carb_g"] or 0)
-            target["fat"] += float(row["fat_g"] or 0)
+            target["ids"].append(int(row["id"]))
+            for source, dest in (("kcal", "kcal"), ("protein_g", "protein"), ("carb_g", "carb"), ("fat_g", "fat")):
+                if row[source] is None:
+                    target["missing"].add(dest)
+                else:
+                    target[dest] += float(row[source])
             if row["sighi_score"] is None:
@@ -309,2 +424,3 @@
         label = _score_label(max_score, int(bucket["unknown"]), float(bucket["load"]))
+        values = {key: None if key in bucket["missing"] else round(float(bucket[key]), 1) for key in ("kcal", "protein", "carb", "fat")}
         connection.execute(
@@ -313,3 +429,3 @@
                VALUES(?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
-            (day, meal, round(bucket["kcal"], 1), round(bucket["protein"], 1), round(bucket["carb"], 1), round(bucket["fat"], 1), int(bucket["items"]), "{}", round(bucket["load"], 1), label),
+            (day, meal, values["kcal"], values["protein"], values["carb"], values["fat"], int(bucket["items"]), _nutrient_totals(connection, bucket["ids"]), round(bucket["load"], 1) if not bucket["unknown"] else None, label),
         )
@@ -321,3 +437,15 @@
            VALUES(?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
-        (day, round(totals["kcal"], 1), round(totals["protein"], 1), round(totals["carb"], 1), round(totals["fat"], 1), int(totals["items"]), "{}", round(totals["load"], 1), max_score, int(totals["unknown"]), label),
+        (
+            day,
+            None if "kcal" in totals["missing"] else round(float(totals["kcal"]), 1),
+            None if "protein" in totals["missing"] else round(float(totals["protein"]), 1),
+            None if "carb" in totals["missing"] else round(float(totals["carb"]), 1),
+            None if "fat" in totals["missing"] else round(float(totals["fat"]), 1),
+            int(totals["items"]),
+            _nutrient_totals(connection, totals["ids"]),
+            round(totals["load"], 1) if not totals["unknown"] else None,
+            max_score,
+            int(totals["unknown"]),
+            label,
+        ),
     )
@@ -345,8 +473,12 @@
             canonical = payload["canonical_food"]
-            score = payload["sighi_score"]
-            light = payload["traffic_light"]
-            affected = sorted({row["datum"] for row in connection.execute("SELECT datum FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)) if row["datum"]})
+            score = None if payload["sighi_score"] == "unknown" else payload["sighi_score"]
+            item_rows = [
+                row for row in connection.execute("SELECT id,datum,name,source_product_id FROM nutrition_items")
+                if _slug(str(row["name"] or row["source_product_id"] or "")) == payload["queue_key"]
+                or _slug(str(row["source_product_id"] or row["name"] or "")) == payload["queue_key"]
+            ]
+            affected = sorted({row["datum"] for row in item_rows if row["datum"]})
             if len(affected) > 366:
                 raise RuntimeError("affected day limit exceeded")
-            if re.search(r"\+|,|;|\bmit\b|\bund\b", str(matched[0]["example_name"]), re.I):
+            if payload["decision"] == "composite":
                 connection.execute(
@@ -356,31 +488,43 @@
                     (normalized, canonical),
-                )
-                score = None
-                light = "unknown"
-            for row in connection.execute("SELECT id FROM nutrition_items WHERE lower(name)=lower(?)", (matched[0]["example_name"],)):
-                connection.execute(
-                    """INSERT OR REPLACE INTO nutrition_histamine_scores
-                       (item_id,canonical_food,sighi_score,traffic_light,tags,confidence,reason,scored_at)
-                       VALUES(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
-                    (row["id"], canonical or None, score, light, "worker_mapping_v1", "medium", "local validated mapping action"),
                 )
-            connection.execute("UPDATE nutrition_review_queue SET status='mapped',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
-            if payload["personal_tolerance"] != "unknown" and canonical:
-                connection.execute(
-                    """INSERT INTO personal_food_tolerance(canonical_food,personal_status,evidence_level,notes,updated_at)
-                       VALUES(?,?, 'low', 'separate personal tolerance label from mapping action', CURRENT_TIMESTAMP)
-                       ON CONFLICT(canonical_food) DO UPDATE SET personal_status=excluded.personal_status,updated_at=CURRENT_TIMESTAMP""",
-                    (canonical, payload["personal_tolerance"]),
-                )
+            elif payload["decision"] == "assign":
+                for row in item_rows:
+                    connection.execute(
+                        """INSERT OR REPLACE INTO nutrition_histamine_scores
+                           (item_id,canonical_food,sighi_score,traffic_light,tags,confidence,reason,scored_at)
+                           VALUES(?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+                        (row["id"], canonical, score, "classified", payload["mapping_method"], payload["confidence"], payload["note"] or "local validated mapping action"),
+                    )
+                connection.execute("UPDATE nutrition_review_queue SET status='mapped',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
+            else:
+                connection.execute("UPDATE nutrition_review_queue SET status='ignored',updated_at=CURRENT_TIMESTAMP WHERE normalized_name=?", (normalized,))
             for day in affected:
-                recompute_nutrition_day(connection, str(day))
+                if payload["decision"] == "assign":
+                    recompute_nutrition_day(connection, str(day))
                 connection.execute(
                     """INSERT OR IGNORE INTO nutrition_mapping_provenance
-                       (action_id,normalized_name,canonical_food,sighi_score,traffic_light,mapping_scope,affected_day)
-                       VALUES(?,?,?,?,?,?,?)""",
-                    (action_id, normalized, canonical or None, score, light, payload["mapping_scope"], str(day)),
+                       (action_id,normalized_name,decision,alias,canonical_food,sighi_score,confidence,
+                        mapping_method,source_label,source_version,note,ingredient_review_required,
+                        personal_tolerance,definition_version,affected_day)
+                       VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+                    (
+                        action_id, normalized, payload["decision"], payload["alias"], canonical or None,
+                        score, payload["confidence"], payload["mapping_method"], payload["source_label"],
+                        payload["source_version"], payload["note"], int(payload["ingredient_review_required"]),
+                        payload.get("personal_tolerance"), HISTAMINE_DEFINITION_VERSION, str(day),
+                    ),
                 )
             connection.execute(
-                "INSERT INTO nutrition_mapping_action_log(action_id,action_hash,queue_key) VALUES(?,?,?)",
-                (action_id, action_hash, payload["queue_key"]),
+                """INSERT INTO nutrition_mapping_action_log
+                   (action_id,action_hash,queue_key,decision,alias,canonical_food,sighi_score,confidence,
+                    mapping_method,source_label,source_version,note,ingredient_review_required,
+                    personal_tolerance,definition_version)
+                   VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+                (
+                    action_id, action_hash, payload["queue_key"], payload["decision"], payload["alias"],
+                    canonical or None, score, payload["confidence"], payload["mapping_method"],
+                    payload["source_label"], payload["source_version"], payload["note"],
+                    int(payload["ingredient_review_required"]), payload.get("personal_tolerance"),
+                    HISTAMINE_DEFINITION_VERSION,
+                ),
             )
@@ -424,3 +568,3 @@
         if DASHBOARD_V5_PROFILE == V5_PROFILE_HEALTH_RECORD_6E:
-            v5_command.append("--health-record-6e")
+            v5_command.extend(["--health-record-6e", "--explorer-6c", "--calendar-day-6d"])
         subprocess.run(


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/health_dashboard_action_worker.py


The worker now validates the new exact action shape, writes idempotent audit/provenance rows, and no longer classifies composites or changes mappings on ignore. I’m aligning the POST form and public API next so the browser can only enqueue that same contract.


Input:
*** Begin Patch
*** Update File: scripts/health/health_dashboard_server.py
@@
-MAPPING_SCORES = {"0", "1", "2", "3", "unknown"}
-MAPPING_LIGHTS = {"green", "yellow", "orange", "red", "unknown"}
-MAPPING_SCOPES = {"canonical"}
-PERSONAL_TOLERANCE = {"unknown", "safe", "problematic", "unclear"}
+MAPPING_DECISIONS = {"assign", "composite", "ignore"}
+MAPPING_SCORES = {"0", "1", "2", "3", "unknown"}
+MAPPING_CONFIDENCE = {"low", "medium", "high"}
+MAPPING_METHODS = {"sighi_reference", "ingredient_label", "manual_review", "local_alias"}
@@
 def validate_mapping_submission(form: dict[str, list[str]]) -> tuple[dict[str, object], str]:
     required = {
         "csrf_token",
+        "decision",
         "queue_key",
+        "alias",
         "canonical_food",
         "sighi_score",
-        "traffic_light",
-        "mapping_scope",
-        "personal_tolerance",
+        "confidence",
+        "mapping_method",
+        "source_label",
+        "source_version",
+        "note",
+        "ingredient_review_required",
         "return_to",
     }
-    if frozenset(form) != frozenset(required) or any(len(values) != 1 for values in form.values()):
+    allowed = required | {"personal_tolerance"}
+    if not required <= set(form) <= allowed or any(len(values) != 1 for values in form.values()):
         raise ValueError("invalid mapping shape")
     return_to = form["return_to"][0]
     if return_to != "v5":
         raise ValueError("invalid return target")
+    decision = form["decision"][0]
     queue_key = form["queue_key"][0].strip()
+    alias = " ".join(form["alias"][0].split())
     canonical_food = " ".join(form["canonical_food"][0].split())
     score = form["sighi_score"][0]
-    traffic_light = form["traffic_light"][0]
-    scope = form["mapping_scope"][0]
-    tolerance = form["personal_tolerance"][0]
+    confidence = form["confidence"][0]
+    method = form["mapping_method"][0]
+    source_label = " ".join(form["source_label"][0].split())
+    source_version = " ".join(form["source_version"][0].split())
+    note = " ".join(form["note"][0].split())
+    review = form["ingredient_review_required"][0]
+    tolerance = " ".join(form.get("personal_tolerance", [""])[0].split())
     if not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
         raise ValueError("invalid queue key")
-    if len(canonical_food) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical_food, re.I):
-        raise ValueError("invalid canonical food")
-    if score not in MAPPING_SCORES or traffic_light not in MAPPING_LIGHTS or scope not in MAPPING_SCOPES or tolerance not in PERSONAL_TOLERANCE:
+    for value, maximum in ((alias, 160), (canonical_food, 120), (source_label, 80), (source_version, 40), (note, 300), (tolerance, 160)):
+        if len(value) > maximum or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", value, re.I):
+            raise ValueError("invalid mapping text")
+    if not alias or not source_label or not source_version:
+        raise ValueError("mapping documentation required")
+    if decision not in MAPPING_DECISIONS or score not in MAPPING_SCORES or confidence not in MAPPING_CONFIDENCE or method not in MAPPING_METHODS or review not in {"true", "false"}:
         raise ValueError("invalid mapping value")
-    if score == "unknown" and traffic_light != "unknown":
-        raise ValueError("unknown score must keep unknown light")
-    if score != "unknown" and not canonical_food:
-        raise ValueError("canonical food required")
+    ingredient_review_required = review == "true"
+    if decision == "assign" and (score == "unknown" or not canonical_food or ingredient_review_required):
+        raise ValueError("assign requires canonical score")
+    if decision == "composite" and (score != "unknown" or not ingredient_review_required):
+        raise ValueError("composite remains unclassified")
+    if decision == "ignore" and (score != "unknown" or canonical_food or ingredient_review_required):
+        raise ValueError("ignore must not classify")
     return {
         "version": 1,
         "action": "nutrition_mapping",
+        "decision": decision,
         "queue_key": queue_key,
+        "alias": alias,
         "canonical_food": canonical_food,
-        "sighi_score": None if score == "unknown" else int(score),
-        "traffic_light": traffic_light,
-        "mapping_scope": scope,
-        "personal_tolerance": tolerance,
+        "sighi_score": "unknown" if score == "unknown" else int(score),
+        "confidence": confidence,
+        "mapping_method": method,
+        "source_label": source_label,
+        "source_version": source_version,
+        "note": note,
+        "ingredient_review_required": ingredient_review_required,
+        **({"personal_tolerance": tolerance} if tolerance else {}),
     }, return_to
@@
         try:
             form = parse_qs(
                 self.rfile.read(length).decode("utf-8"),
                 keep_blank_values=True,
                 strict_parsing=True,
+                max_num_fields=20,
             )
*** End Patch


Success. Updated the following files:
M scripts/health/health_dashboard_server.py

@@ -135,6 +135,6 @@
 SYMPTOM_FIELDS = ("aphthen", "gi", "fatigue", "skin", "eyes", "joints", "vascular")
+MAPPING_DECISIONS = {"assign", "composite", "ignore"}
 MAPPING_SCORES = {"0", "1", "2", "3", "unknown"}
-MAPPING_LIGHTS = {"green", "yellow", "orange", "red", "unknown"}
-MAPPING_SCOPES = {"canonical"}
-PERSONAL_TOLERANCE = {"unknown", "safe", "problematic", "unclear"}
+MAPPING_CONFIDENCE = {"low", "medium", "high"}
+MAPPING_METHODS = {"sighi_reference", "ingredient_label", "manual_review", "local_alias"}
 CSRF_TTL_SECONDS = max(
@@ -506,11 +506,17 @@
         "csrf_token",
+        "decision",
         "queue_key",
+        "alias",
         "canonical_food",
         "sighi_score",
-        "traffic_light",
-        "mapping_scope",
-        "personal_tolerance",
+        "confidence",
+        "mapping_method",
+        "source_label",
+        "source_version",
+        "note",
+        "ingredient_review_required",
         "return_to",
     }
-    if frozenset(form) != frozenset(required) or any(len(values) != 1 for values in form.values()):
+    allowed = required | {"personal_tolerance"}
+    if not required <= set(form) <= allowed or any(len(values) != 1 for values in form.values()):
         raise ValueError("invalid mapping shape")
@@ -519,18 +525,30 @@
         raise ValueError("invalid return target")
+    decision = form["decision"][0]
     queue_key = form["queue_key"][0].strip()
+    alias = " ".join(form["alias"][0].split())
     canonical_food = " ".join(form["canonical_food"][0].split())
     score = form["sighi_score"][0]
-    traffic_light = form["traffic_light"][0]
-    scope = form["mapping_scope"][0]
-    tolerance = form["personal_tolerance"][0]
+    confidence = form["confidence"][0]
+    method = form["mapping_method"][0]
+    source_label = " ".join(form["source_label"][0].split())
+    source_version = " ".join(form["source_version"][0].split())
+    note = " ".join(form["note"][0].split())
+    review = form["ingredient_review_required"][0]
+    tolerance = " ".join(form.get("personal_tolerance", [""])[0].split())
     if not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
         raise ValueError("invalid queue key")
-    if len(canonical_food) > 120 or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", canonical_food, re.I):
-        raise ValueError("invalid canonical food")
-    if score not in MAPPING_SCORES or traffic_light not in MAPPING_LIGHTS or scope not in MAPPING_SCOPES or tolerance not in PERSONAL_TOLERANCE:
+    for value, maximum in ((alias, 160), (canonical_food, 120), (source_label, 80), (source_version, 40), (note, 300), (tolerance, 160)):
+        if len(value) > maximum or re.search(r"[\x00-\x1f\x7f]|https?://|/|\\\\|\.hermes", value, re.I):
+            raise ValueError("invalid mapping text")
+    if not alias or not source_label or not source_version:
+        raise ValueError("mapping documentation required")
+    if decision not in MAPPING_DECISIONS or score not in MAPPING_SCORES or confidence not in MAPPING_CONFIDENCE or method not in MAPPING_METHODS or review not in {"true", "false"}:
         raise ValueError("invalid mapping value")
-    if score == "unknown" and traffic_light != "unknown":
-        raise ValueError("unknown score must keep unknown light")
-    if score != "unknown" and not canonical_food:
-        raise ValueError("canonical food required")
+    ingredient_review_required = review == "true"
+    if decision == "assign" and (score == "unknown" or not canonical_food or ingredient_review_required):
+        raise ValueError("assign requires canonical score")
+    if decision == "composite" and (score != "unknown" or not ingredient_review_required):
+        raise ValueError("composite remains unclassified")
+    if decision == "ignore" and (score != "unknown" or canonical_food or ingredient_review_required):
+        raise ValueError("ignore must not classify")
     return {
@@ -538,8 +556,14 @@
         "action": "nutrition_mapping",
+        "decision": decision,
         "queue_key": queue_key,
+        "alias": alias,
         "canonical_food": canonical_food,
-        "sighi_score": None if score == "unknown" else int(score),
-        "traffic_light": traffic_light,
-        "mapping_scope": scope,
-        "personal_tolerance": tolerance,
+        "sighi_score": "unknown" if score == "unknown" else int(score),
+        "confidence": confidence,
+        "mapping_method": method,
+        "source_label": source_label,
+        "source_version": source_version,
+        "note": note,
+        "ingredient_review_required": ingredient_review_required,
+        **({"personal_tolerance": tolerance} if tolerance else {}),
     }, return_to
@@ -693,2 +717,3 @@
                 strict_parsing=True,
+                max_num_fields=20,
             )


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/health_dashboard_server.py


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/read_api.py
@@
 NUTRIENT_ALLOWLIST = {
     "energy.energy": ("Energie", "kcal"),
     "nutrient.protein": ("Protein", "g"),
     "nutrient.carb": ("Kohlenhydrate", "g"),
     "nutrient.fat": ("Fett", "g"),
     "nutrient.fiber": ("Ballaststoffe", "g"),
     "nutrient.sugar": ("Zucker", "g"),
+    "nutrient.saturated_fat": ("Gesättigte Fettsäuren", "g"),
     "nutrient.salt": ("Salz", "g"),
     "nutrient.sodium": ("Natrium", "mg"),
-    "nutrient.vitamin_c": ("Vitamin C", "mg"),
-    "nutrient.calcium": ("Calcium", "mg"),
-    "nutrient.iron": ("Eisen", "mg"),
-    "nutrient.magnesium": ("Magnesium", "mg"),
 }
 MEAL_LABELS = {
     "breakfast": "Frühstück",
     "lunch": "Mittagessen",
     "dinner": "Abendessen",
     "snack": "Snack",
-    "unknown": "Unbekannt",
+    "unassigned": "Nicht zugeordnet",
 }
@@
 def _nutrition_complete(row: sqlite3.Row) -> bool:
     return (
         int(row["item_count"] or 0) > 0
         and int(row["histamine_unknown_count"] or 0) == 0
         and row["histamine_score"] is not None
-        and row["histamine_label"] in {"green", "yellow", "orange", "red"}
+        and row["histamine_label"] in {"classified", "green", "yellow", "orange", "red"}
     )
@@
 def _nutrition_days(connection: sqlite3.Connection, params: dict[str, str]) -> dict[str, Any]:
@@
         days.append({
             "date": day,
@@
             "histamine": {
                 "score": _public_number(row["histamine_score"]) if complete else None,
-                "traffic_light": row["histamine_label"] if complete else "unknown",
+                "max_score": int(row["histamine_score"]) if complete and row["histamine_score"] is not None else None,
+                "mapping_coverage": round((int(row["item_count"] or 0) - int(row["histamine_unknown_count"] or 0)) / int(row["item_count"] or 1), 3) if int(row["item_count"] or 0) else 0,
                 "unknown_items": int(row["histamine_unknown_count"] or 0),
                 "status": "complete" if complete else "incomplete",
+                "definition_version": _histamine_definition()["version"],
             },
         })
@@
 def _histamine_definition() -> dict[str, Any]:
     return {
         "id": "sighi_mapping_load_v1",
-        "version": 1,
-        "components": ["mapped_item_scores", "unknown_item_count", "traffic_light"],
+        "version": "sighi_mapping_load_v2",
+        "components": ["mapped_item_score_sum", "mapped_item_score_max", "mapping_coverage", "unknown_item_count"],
         "coverage_semantics": "unknown_or_unmapped_items_make_day_incomplete",
         "insufficient_inputs": "no_index",
+        "score_scale": "SIGHi 0-3 or unknown per mapped item",
+        "complete_rule": "complete only when every documented nutrition item has a mapping",
     }
@@
-    try:
-        raw_nutrients = json.loads(row["nutrient_json"] or "{}")
-    except json.JSONDecodeError:
-        raw_nutrients = {}
-    if isinstance(raw_nutrients, dict):
-        for key, (label, unit) in NUTRIENT_ALLOWLIST.items():
-            nutrients[key] = {"label": label, "value": _public_number(raw_nutrients.get(key)), "unit": unit}
+    nutrient_values: dict[str, int | float | None] = {}
+    if table_exists(connection, "nutrition_item_nutrients"):
+        for nutrient in connection.execute(
+            """SELECT n.nutrient_key,n.value,n.unit
+               FROM nutrition_item_nutrients n JOIN nutrition_items i ON i.id=n.item_id
+               WHERE i.datum=?""",
+            (day,),
+        ):
+            key = str(nutrient["nutrient_key"] or "")
+            allowed = NUTRIENT_ALLOWLIST.get(key)
+            if not allowed or str(nutrient["unit"] or "") != allowed[1]:
+                continue
+            value = _public_number(nutrient["value"])
+            if value is None:
+                continue
+            nutrient_values[key] = _public_number(float(nutrient_values.get(key) or 0) + float(value))
+    else:
+        try:
+            raw_nutrients = json.loads(row["nutrient_json"] or "{}")
+        except json.JSONDecodeError:
+            raw_nutrients = {}
+        if isinstance(raw_nutrients, dict):
+            for key in NUTRIENT_ALLOWLIST:
+                value = _public_number(raw_nutrients.get(key))
+                if value is not None:
+                    nutrient_values[key] = value
+    for key, (label, unit) in NUTRIENT_ALLOWLIST.items():
+        if key in nutrient_values:
+            nutrients[key] = {"label": label, "value": nutrient_values[key], "unit": unit}
@@
-                    "traffic_light": meal["histamine_label"] if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else "unknown",
+                    "status": "complete" if meal["histamine_label"] in {"classified", "green", "yellow", "orange", "red"} else "incomplete",
                 },
             })
+    existing_meals = {meal["meal"] for meal in meals}
+    for meal_id, label in MEAL_LABELS.items():
+        if meal_id not in existing_meals:
+            meals.append({"meal": meal_id, "label": label, "item_count": 0, "macros": {"kcal": None, "protein_g": None, "carb_g": None, "fat_g": None}, "histamine": {"score": None, "status": "incomplete"}})
+    meals.sort(key=lambda item: ["breakfast", "lunch", "dinner", "snack", "unassigned"].index(item["meal"]))
@@
-                "meal": item["meal"] if item["meal"] in MEAL_LABELS else "unknown",
+                "meal": item["meal"] if item["meal"] in MEAL_LABELS else "unassigned",
@@
                     "canonical_food": canonical or None,
                     "score": int(item["sighi_score"]) if item["sighi_score"] in (0, 1, 2, 3) else None,
-                    "traffic_light": item["traffic_light"] if item["traffic_light"] in {"green", "yellow", "orange", "red"} else "unknown",
+                    "status": "mapped" if item["sighi_score"] in (0, 1, 2, 3) else "unmapped",
                     "confidence": item["confidence"] if item["confidence"] in {"low", "medium", "high"} else "low",
                 },
             })
@@
             "status": "complete" if complete else "incomplete",
             "item_count": int(row["item_count"] or 0),
             "unknown_items": int(row["histamine_unknown_count"] or 0),
+            "mapping_coverage": round((int(row["item_count"] or 0) - int(row["histamine_unknown_count"] or 0)) / int(row["item_count"] or 1), 3) if int(row["item_count"] or 0) else 0,
             "insufficient_inputs": None if complete else "no_index",
         },
@@
         "histamine": {
             "score": _public_number(row["histamine_score"]) if complete else None,
-            "traffic_light": row["histamine_label"] if complete else "unknown",
+            "max_score": _public_number(row["histamine_score"]) if complete else None,
+            "unknown_items": int(row["histamine_unknown_count"] or 0),
+            "definition_version": _histamine_definition()["version"],
         },
@@
             "suggested_canonical_food": suggested,
-            "suggested_score": int(row["suggested_score"]) if row["suggested_score"] in (0, 1, 2, 3) else None,
+            "suggested_score": int(row["suggested_score"]) if row["suggested_score"] in (0, 1, 2, 3) else "unknown",
             "reason": reason,
             "status": "open",
             "action_contract": "nutrition_mapping_action_v1",
+            "allowed_decisions": ["assign", "composite", "ignore"],
+            "allowed_confidence": ["low", "medium", "high"],
+            "allowed_mapping_methods": ["sighi_reference", "ingredient_label", "manual_review", "local_alias"],
         })
*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py

@@ -78,8 +78,5 @@
     "nutrient.sugar": ("Zucker", "g"),
+    "nutrient.saturated_fat": ("Gesättigte Fettsäuren", "g"),
     "nutrient.salt": ("Salz", "g"),
     "nutrient.sodium": ("Natrium", "mg"),
-    "nutrient.vitamin_c": ("Vitamin C", "mg"),
-    "nutrient.calcium": ("Calcium", "mg"),
-    "nutrient.iron": ("Eisen", "mg"),
-    "nutrient.magnesium": ("Magnesium", "mg"),
 }
@@ -90,3 +87,3 @@
     "snack": "Snack",
-    "unknown": "Unbekannt",
+    "unassigned": "Nicht zugeordnet",
 }
@@ -1240,3 +1237,3 @@
         and row["histamine_score"] is not None
-        and row["histamine_label"] in {"green", "yellow", "orange", "red"}
+        and row["histamine_label"] in {"classified", "green", "yellow", "orange", "red"}
     )
@@ -1291,5 +1288,7 @@
                 "score": _public_number(row["histamine_score"]) if complete else None,
-                "traffic_light": row["histamine_label"] if complete else "unknown",
+                "max_score": int(row["histamine_score"]) if complete and row["histamine_score"] is not None else None,
+                "mapping_coverage": round((int(row["item_count"] or 0) - int(row["histamine_unknown_count"] or 0)) / int(row["item_count"] or 1), 3) if int(row["item_count"] or 0) else 0,
                 "unknown_items": int(row["histamine_unknown_count"] or 0),
                 "status": "complete" if complete else "incomplete",
+                "definition_version": _histamine_definition()["version"],
             },
@@ -1302,6 +1301,8 @@
         "id": "sighi_mapping_load_v1",
-        "version": 1,
-        "components": ["mapped_item_scores", "unknown_item_count", "traffic_light"],
+        "version": "sighi_mapping_load_v2",
+        "components": ["mapped_item_score_sum", "mapped_item_score_max", "mapping_coverage", "unknown_item_count"],
         "coverage_semantics": "unknown_or_unmapped_items_make_day_incomplete",
         "insufficient_inputs": "no_index",
+        "score_scale": "SIGHi 0-3 or unknown per mapped item",
+        "complete_rule": "complete only when every documented nutrition item has a mapping",
     }
@@ -1323,9 +1324,31 @@
     nutrients: dict[str, dict[str, Any]] = {}
-    try:
-        raw_nutrients = json.loads(row["nutrient_json"] or "{}")
-    except json.JSONDecodeError:
-        raw_nutrients = {}
-    if isinstance(raw_nutrients, dict):
-        for key, (label, unit) in NUTRIENT_ALLOWLIST.items():
-            nutrients[key] = {"label": label, "value": _public_number(raw_nutrients.get(key)), "unit": unit}
+    nutrient_values: dict[str, int | float | None] = {}
+    if table_exists(connection, "nutrition_item_nutrients"):
+        for nutrient in connection.execute(
+            """SELECT n.nutrient_key,n.value,n.unit
+               FROM nutrition_item_nutrients n JOIN nutrition_items i ON i.id=n.item_id
+               WHERE i.datum=?""",
+            (day,),
+        ):
+            key = str(nutrient["nutrient_key"] or "")
+            allowed = NUTRIENT_ALLOWLIST.get(key)
+            if not allowed or str(nutrient["unit"] or "") != allowed[1]:
+                continue
+            value = _public_number(nutrient["value"])
+            if value is None:
+                continue
+            nutrient_values[key] = _public_number(float(nutrient_values.get(key) or 0) + float(value))
+    else:
+        try:
+            raw_nutrients = json.loads(row["nutrient_json"] or "{}")
+        except json.JSONDecodeError:
+            raw_nutrients = {}
+        if isinstance(raw_nutrients, dict):
+            for key in NUTRIENT_ALLOWLIST:
+                value = _public_number(raw_nutrients.get(key))
+                if value is not None:
+                    nutrient_values[key] = value
+    for key, (label, unit) in NUTRIENT_ALLOWLIST.items():
+        if key in nutrient_values:
+            nutrients[key] = {"label": label, "value": nutrient_values[key], "unit": unit}
     meals = []
@@ -1351,5 +1374,10 @@
                     "score": _public_number(meal["histamine_score"]) if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else None,
-                    "traffic_light": meal["histamine_label"] if meal["histamine_label"] in {"green", "yellow", "orange", "red"} else "unknown",
+                    "status": "complete" if meal["histamine_label"] in {"classified", "green", "yellow", "orange", "red"} else "incomplete",
                 },
             })
+    existing_meals = {meal["meal"] for meal in meals}
+    for meal_id, label in MEAL_LABELS.items():
+        if meal_id not in existing_meals:
+            meals.append({"meal": meal_id, "label": label, "item_count": 0, "macros": {"kcal": None, "protein_g": None, "carb_g": None, "fat_g": None}, "histamine": {"score": None, "status": "incomplete"}})
+    meals.sort(key=lambda item: ["breakfast", "lunch", "dinner", "snack", "unassigned"].index(item["meal"]))
     items = []
@@ -1371,3 +1399,3 @@
             items.append({
-                "meal": item["meal"] if item["meal"] in MEAL_LABELS else "unknown",
+                "meal": item["meal"] if item["meal"] in MEAL_LABELS else "unassigned",
                 "name": name,
@@ -1385,3 +1413,3 @@
                     "score": int(item["sighi_score"]) if item["sighi_score"] in (0, 1, 2, 3) else None,
-                    "traffic_light": item["traffic_light"] if item["traffic_light"] in {"green", "yellow", "orange", "red"} else "unknown",
+                    "status": "mapped" if item["sighi_score"] in (0, 1, 2, 3) else "unmapped",
                     "confidence": item["confidence"] if item["confidence"] in {"low", "medium", "high"} else "low",
@@ -1399,2 +1427,3 @@
             "unknown_items": int(row["histamine_unknown_count"] or 0),
+            "mapping_coverage": round((int(row["item_count"] or 0) - int(row["histamine_unknown_count"] or 0)) / int(row["item_count"] or 1), 3) if int(row["item_count"] or 0) else 0,
             "insufficient_inputs": None if complete else "no_index",
@@ -1410,3 +1439,5 @@
             "score": _public_number(row["histamine_score"]) if complete else None,
-            "traffic_light": row["histamine_label"] if complete else "unknown",
+            "max_score": _public_number(row["histamine_score"]) if complete else None,
+            "unknown_items": int(row["histamine_unknown_count"] or 0),
+            "definition_version": _histamine_definition()["version"],
         },
@@ -1445,3 +1476,3 @@
             "suggested_canonical_food": suggested,
-            "suggested_score": int(row["suggested_score"]) if row["suggested_score"] in (0, 1, 2, 3) else None,
+            "suggested_score": int(row["suggested_score"]) if row["suggested_score"] in (0, 1, 2, 3) else "unknown",
             "reason": reason,
@@ -1449,2 +1480,5 @@
             "action_contract": "nutrition_mapping_action_v1",
+            "allowed_decisions": ["assign", "composite", "ignore"],
+            "allowed_confidence": ["low", "medium", "high"],
+            "allowed_mapping_methods": ["sighi_reference", "ingredient_label", "manual_review", "local_alias"],
         })


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/read_api.py
@@
-    sql = """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,
-                    histamine_unknown_count,histamine_label
+    sql = """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,
+                    histamine_max,histamine_unknown_count,histamine_label
@@
-                "max_score": int(row["histamine_score"]) if complete and row["histamine_score"] is not None else None,
+                "max_score": int(row["histamine_max"] or 0) if complete else None,
@@
-        """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,
-                  histamine_score,histamine_unknown_count,histamine_label
+        """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,
+                  histamine_score,histamine_max,histamine_unknown_count,histamine_label
@@
-            "max_score": _public_number(row["histamine_score"]) if complete else None,
+            "max_score": int(row["histamine_max"] or 0) if complete else None,
*** End Patch


@@ -1257,3 +1257,3 @@
     sql = """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,histamine_score,
-                    histamine_unknown_count,histamine_label
+                    histamine_max,histamine_unknown_count,histamine_label
              FROM nutrition_daily_summary_v2 WHERE datum<=?"""
@@ -1288,3 +1288,3 @@
                 "score": _public_number(row["histamine_score"]) if complete else None,
-                "max_score": int(row["histamine_score"]) if complete and row["histamine_score"] is not None else None,
+                "max_score": int(row["histamine_max"] or 0) if complete else None,
                 "mapping_coverage": round((int(row["item_count"] or 0) - int(row["histamine_unknown_count"] or 0)) / int(row["item_count"] or 1), 3) if int(row["item_count"] or 0) else 0,
@@ -1316,3 +1316,3 @@
         """SELECT datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,
-                  histamine_score,histamine_unknown_count,histamine_label
+                  histamine_score,histamine_max,histamine_unknown_count,histamine_label
            FROM nutrition_daily_summary_v2 WHERE datum=? LIMIT 1""",
@@ -1439,3 +1439,3 @@
             "score": _public_number(row["histamine_score"]) if complete else None,
-            "max_score": _public_number(row["histamine_score"]) if complete else None,
+            "max_score": int(row["histamine_max"] or 0) if complete else None,
             "unknown_items": int(row["histamine_unknown_count"] or 0),


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/read_api.py


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/data_provider.py
@@
-                and row["histamine_label"] in {"green", "yellow", "orange", "red"}
+                and row["histamine_label"] in {"classified", "green", "yellow", "orange", "red"}
@@
-                    "histamine_status": str(row["histamine_label"]) if complete else "unknown",
+                    "histamine_status": "classified" if complete else "unknown",
@@
-                    "suggested_score": score if score is not None else "unknown",
-                    "suggested_traffic_light": "unknown",
+                    "suggested_score": score if score is not None else "unknown",
*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/data_provider.py

@@ -248,3 +248,3 @@
                 and row["histamine_score"] is not None
-                and row["histamine_label"] in {"green", "yellow", "orange", "red"}
+                and row["histamine_label"] in {"classified", "green", "yellow", "orange", "red"}
             )
@@ -254,3 +254,3 @@
                     "histamine_score": row["histamine_score"] if complete else None,
-                    "histamine_status": str(row["histamine_label"]) if complete else "unknown",
+                    "histamine_status": "classified" if complete else "unknown",
                     "unknown_items": row["histamine_unknown_count"], "quality": "complete" if complete else "incomplete",
@@ -272,3 +272,2 @@
                     "suggested_score": score if score is not None else "unknown",
-                    "suggested_traffic_light": "unknown",
                 })


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/data_provider.py


The API now rebuilds nutrient output from `nutrition_item_nutrients` when available and only includes allowlisted nutrients with expected units. I’m tightening the bundle contract and UI to remove the traffic-light vocabulary and make the mapping workspace actually collect the required provenance fields.


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/contracts.py
@@
-        if row["histamine_status"] not in {"green", "yellow", "orange", "red", "unknown"}:
+        if row["histamine_status"] not in {"classified", "green", "yellow", "orange", "red", "unknown"}:
@@
-            if row["histamine_score"] is None or row["histamine_status"] == "unknown" or row["unknown_items"] != 0:
+            if row["histamine_score"] is None or row["histamine_status"] == "unknown" or row["unknown_items"] != 0:
@@
         allowed = {
             "name", "occurrences", "reason", "queue_key", "suggested_canonical_food",
-            "suggested_score", "suggested_traffic_light",
+            "suggested_score",
         }
@@
-        if "suggested_traffic_light" in row and row["suggested_traffic_light"] not in {"green", "yellow", "orange", "red", "unknown"}:
-            raise ValueError(f"invalid suggested traffic light at {path}")
*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/contracts.py

@@ -241,3 +241,3 @@
                 raise ValueError(f"invalid count at {path}.{field}")
-        if row["histamine_status"] not in {"green", "yellow", "orange", "red", "unknown"}:
+        if row["histamine_status"] not in {"classified", "green", "yellow", "orange", "red", "unknown"}:
             raise ValueError(f"invalid histamine status at {path}")
@@ -255,3 +255,3 @@
             "name", "occurrences", "reason", "queue_key", "suggested_canonical_food",
-            "suggested_score", "suggested_traffic_light",
+            "suggested_score",
         }
@@ -271,4 +271,2 @@
             raise ValueError(f"invalid suggested score at {path}")
-        if "suggested_traffic_light" in row and row["suggested_traffic_light"] not in {"green", "yellow", "orange", "red", "unknown"}:
-            raise ValueError(f"invalid suggested traffic light at {path}")


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/contracts.py


The public bundle contract now accepts only neutral classified/unknown semantics for Sprint 6G nutrition data. I’m updating the visible workspace so every POST includes decision, alias, score, confidence, method, source, note, ingredient-review flag, and no legacy traffic-light fields.


Input:
*** Begin Patch
*** Update File: scripts/health/dashboard_v5/render.py
@@
-

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Nur Anzeige – Bearbeitung folgt im Ernährungs-Sprint 6G. Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

+

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen, Version und Unsicherheit; sie sind keine medizinische Bewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Gegenüberstellung gemeinsam dokumentierter Werte; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

*** End Patch


Success. Updated the following files:
M scripts/health/dashboard_v5/render.py

@@ -156,3 +156,3 @@
 {calendar_markup}
-

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen und Unsicherheit; sie sind keine allgemeine Lebensmittelsicherheitsbewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Hypothesenansicht: gemeinsam dokumentierte Werte werden transparent gegenübergestellt; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Nur Anzeige – Bearbeitung folgt im Ernährungs-Sprint 6G. Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

+

Ernährung

SIGHi-Zuordnungen dokumentieren Quellen, Version und Unsicherheit; sie sind keine medizinische Bewertung.

Keine vollständig klassifizierten Ernährungstage im gewählten Zeitraum.
Zugängliche Datentabelle

Ernährungstag
Datum <input type='date' data-nutrition-day-date><button type='button' data-nutrition-day-open>Tag laden</button>

Histamin und Aphthen

Neutrale Gegenüberstellung gemeinsam dokumentierter Werte; unvollständige Eingaben ergeben keinen Index.

Keine vollständigen Datenpaare im gewählten Zeitraum.

Offene Lebensmittel-Zuordnungen

Validierte Aktionen werden lokal vorgemerkt und erst vom privaten Worker angewendet.

 {record_markup}


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M scripts/health/dashboard_v5/render.py


Input:
*** Begin Patch
*** Update File: scripts/health/assets/health-assets/dashboard-v5.js
@@
-      card.append(node("h3", "v5-label", "Letzter vollständig klassifizierter Histamin-Score"));
+      card.append(node("h3", "v5-label", "Letzter vollständig klassifizierter Histamin-Index"));
@@
         row.append(node("strong", "", item.name));
         row.append(node("div", "v5-meta", `${item.occurrences} Dokumentation(en) · Status: nicht zugeordnet`));
         const form = node("form", "mapping-action-form");
         form.method = "post";
         form.action = "/health-actions/nutrition-mapping";
-        [["csrf_token", q("#view-nutrition")?.dataset.mappingCsrf || ""], ["return_to", "v5"], ["queue_key", item.queue_key || ""], ["canonical_food", item.suggested_canonical_food || ""], ["sighi_score", item.suggested_score ?? "unknown"], ["traffic_light", item.suggested_traffic_light || "unknown"], ["mapping_scope", "canonical"], ["personal_tolerance", "unknown"]].forEach(([name, value]) => {
+        [
+          ["csrf_token", q("#view-nutrition")?.dataset.mappingCsrf || ""],
+          ["return_to", "v5"],
+          ["queue_key", item.queue_key || ""],
+          ["alias", item.example_name || item.name || ""],
+          ["source_label", "SIGHi"],
+          ["source_version", "unknown"],
+        ].forEach(([name, value]) => {
           const input = node("input");
           input.type = "hidden";
           input.name = name;
           input.value = String(value);
           form.append(input);
         });
-        const submit = node("button", "", "Zuordnung vormerken");
+        const decision = node("select");
+        decision.name = "decision";
+        [["assign", "Zuordnen"], ["composite", "Zutatenprüfung"], ["ignore", "Ignorieren"]].forEach(([value, label]) => {
+          const option = node("option", "", label);
+          option.value = value;
+          decision.append(option);
+        });
+        const canonical = node("input");
+        canonical.name = "canonical_food";
+        canonical.maxLength = 120;
+        canonical.value = item.suggested_canonical_food || "";
+        const score = node("select");
+        score.name = "sighi_score";
+        ["unknown", "0", "1", "2", "3"].forEach((value) => {
+          const option = node("option", "", value === "unknown" ? "SIGHi unbekannt" : `SIGHi ${value}`);
+          option.value = value;
+          if (String(item.suggested_score ?? "unknown") === value) option.selected = true;
+          score.append(option);
+        });
+        const confidence = node("select");
+        confidence.name = "confidence";
+        [["low", "niedrig"], ["medium", "mittel"], ["high", "hoch"]].forEach(([value, label]) => {
+          const option = node("option", "", label);
+          option.value = value;
+          if (value === "medium") option.selected = true;
+          confidence.append(option);
+        });
+        const method = node("select");
+        method.name = "mapping_method";
+        [["sighi_reference", "SIGHi-Referenz"], ["ingredient_label", "Zutatenliste"], ["manual_review", "manuelle Prüfung"], ["local_alias", "lokaler Alias"]].forEach(([value, label]) => {
+          const option = node("option", "", label);
+          option.value = value;
+          method.append(option);
+        });
+        const note = node("input");
+        note.name = "note";
+        note.maxLength = 300;
+        note.value = item.reason || "";
+        const reviewRequired = node("input");
+        reviewRequired.type = "hidden";
+        reviewRequired.name = "ingredient_review_required";
+        reviewRequired.value = "false";
+        decision.addEventListener("change", () => {
+          const composite = decision.value === "composite";
+          const ignore = decision.value === "ignore";
+          reviewRequired.value = composite ? "true" : "false";
+          score.value = composite || ignore ? "unknown" : score.value;
+          canonical.value = ignore ? "" : canonical.value;
+          canonical.disabled = ignore;
+          score.disabled = composite || ignore;
+        });
+        [
+          ["Entscheidung", decision],
+          ["Kanonisches Lebensmittel", canonical],
+          ["SIGHi", score],
+          ["Vertrauen", confidence],
+          ["Methode", method],
+          ["Notiz", note],
+        ].forEach(([labelText, control]) => {
+          const label = node("label", "mapping-field", labelText);
+          label.append(control);
+          form.append(label);
+        });
+        form.append(reviewRequired);
+        const submit = node("button", "", "Aktion vormerken");
         submit.type = "submit";
         submit.disabled = !item.queue_key;
         form.append(submit);
*** End Patch