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


The focused tests found one plain-cursor path in schema bootstrap. I’m removing that row-factory dependency and rerunning the same focused suite.


Input:
*** Begin Patch
*** Update File: scripts/health/health_dashboard_action_worker.py
@@
     }.items():
-        existing = {row["name"] for row in connection.execute(f"PRAGMA table_info({table})")}
+        existing = {
+            row["name"] if isinstance(row, sqlite3.Row) else row[1]
+            for row in connection.execute(f"PRAGMA table_info({table})")
+        }
*** End Patch


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

@@ -403,3 +403,6 @@
     }.items():
-        existing = {row["name"] for row in connection.execute(f"PRAGMA table_info({table})")}
+        existing = {
+            row["name"] if isinstance(row, sqlite3.Row) else row[1]
+            for row in connection.execute(f"PRAGMA table_info({table})")
+        }
         for name, declaration in columns.items():


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


The bootstrap fix is in. I’m rerunning the Sprint 6G test file and the same syntax/diff checks.


python -m pytest tests/test_dashboard_v5_sprint6g_nutrition.py -q


python -m py_compile scripts/health/health_dashboard_action_worker.py scripts/health/health_dashboard_server.py tests/test_dashboard_v5_sprint6g_nutrition.py


Output:
Chunk ID: 7a9628
Wall time: 14.5274 seconds
Process exited with code 0
Original token count: 25
Output:
........                                                                 [100%]
8 passed in 14.31s


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


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


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


The focused Python suite is green. I’m doing one more targeted static pass on the changed Python files with Ruff if available, plus a quick diff review for accidental broad edits.


python -m ruff check scripts/health/health_dashboard_action_worker.py scripts/health/health_dashboard_server.py tests/test_dashboard_v5_sprint6g_nutrition.py


git diff -- database/schema.sql scripts/health/health_dashboard_action_worker.py scripts/health/health_dashboard_server.py scripts/health/assets/health-assets/dashboard-v5.js tests/test_dashboard_v5_sprint6g_nutrition.py docs/adr/0003-dietary-inflammation-model.md


Output:
Chunk ID: 552d1f
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 69
Output:
E402 Module level import not at top of file
  --> tests/test_dashboard_v5_sprint6g_nutrition.py:13:1
   |
11 | ROOT = Path(__file__).resolve().parents[1]
12 |
13 | import sys
   | ^^^^^^^^^^
14 |
15 | sys.path.insert(0, str(ROOT / "scripts" / "health"))
   |

Found 1 error.


Output:
Chunk ID: c8a2be
Wall time: 0.0001 seconds
Process exited with code 0
Original token count: 13853
Output:
Total output lines: 1211

diff --git a/database/schema.sql b/database/schema.sql
index 2ded56a..32a9893 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -311,7 +311,7 @@ CREATE TABLE nutrition_daily_summary_v2 (
     fat_g REAL DEFAULT 0,
     item_count INTEGER DEFAULT 0,
     nutrient_json TEXT,
-    histamine_score REAL DEFAULT 0,
+    histamine_score REAL,
     histamine_max INTEGER DEFAULT 0,
     histamine_unknown_count INTEGER DEFAULT 0,
     histamine_label TEXT DEFAULT 'unknown',
@@ -337,7 +337,7 @@ CREATE TABLE nutrition_histamine_scores (
     item_id INTEGER PRIMARY KEY,
     canonical_food TEXT,
     sighi_score INTEGER,
-    traffic_light TEXT NOT NULL,
+    traffic_light TEXT NOT NULL DEFAULT 'unknown',
     tags TEXT,
     confidence TEXT DEFAULT 'low',
     reason TEXT,
@@ -388,7 +388,7 @@ CREATE TABLE nutrition_meal_summary (
     fat_g REAL DEFAULT 0,
     item_count INTEGER DEFAULT 0,
     nutrient_json TEXT,
-    histamine_score REAL DEFAULT 0,
+    histamine_score REAL,
     histamine_label TEXT DEFAULT 'unknown',
     updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
     PRIMARY KEY (datum, meal)
@@ -423,12 +423,63 @@ CREATE TABLE nutrition_treatment_phase_summary (

 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')),
+    personal_status TEXT NOT NULL DEFAULT 'unknown' CHECK(personal_status IN ('unknown','documented_tolerated','documented_not_tolerated','unclear')),
+    evidence_level TEXT DEFAULT 'user_documented' CHECK(evidence_level IN ('low','medium','high','user_documented')),
     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,
+    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_status TEXT,
+    personal_tolerance_note TEXT,
+    definition_version TEXT,
+    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,
+    decision TEXT,
+    alias TEXT,
+    canonical_food TEXT,
+    sighi_score INTEGER,
+    traffic_light TEXT,
+    mapping_scope TEXT,
+    confidence TEXT,
+    mapping_method TEXT,
+    source_label TEXT,
+    source_version TEXT,
+    note TEXT,
+    ingredient_review_required INTEGER,
+    personal_tolerance_status TEXT,
+    personal_tolerance_note TEXT,
+    definition_version TEXT,
+    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,
diff --git a/scripts/health/assets/health-assets/dashboard-v5.js b/scripts/health/assets/health-assets/dashboard-v5.js
index 9b74e09..a40ab2c 100644
--- a/scripts/health/assets/health-assets/dashboard-v5.js
+++ b/scripts/health/assets/health-assets/dashboard-v5.js
@@ -15,6 +15,28 @@
   const q = (selector, root = document) => root.querySelector(selector);
   const qa = (selector, root = document) => Array.from(root.querySelectorAll(selector));

+  function requestBrowserSession() {
+    if (window.healthApiSessionReady) return window.healthApiSessionReady;
+    const meta = document.querySelector("meta[name='health-browser-session-csrf']");
+    window.healthApiSessionReady = meta?.content ? fetch("/api/v1/browser-session", {
+      method: "POST",
+      credentials: "same-origin",
+      headers: { "X-Health-Browser-CSRF": meta.content },
+    }).then((response) => {
+      if (!response.ok) throw new Error(`session_${response.status}`);
+    }) : Promise.reject(new Error("missing_csrf"));
+    return window.healthApiSessionReady;
+  }
+
+  async function apiGet(path) {
+    await requestBrowserSession();
+    const response = await fetch(path, { credentials: "same-origin" });
+    let data = null;
+    try { data = await response.json(); } catch (_error) { data = null; }
+    if (!response.ok) throw new Error(data?.error || `api_${response.status}`);
+    return data;
+  }
+
   function readBundle() {
     const node = document.getElementById("health-dashboard-bundle");
     if (!node) return {};
@@ -241,41 +263,36 @@
   function sparkline(metricId, label) {
     const rows = metricSeries(metricId).filter((row) => row.value !== null && row.value !== undefined).slice(-45);
     if (!rows.length) return node("div", "v5-empty", "Keine Trendpunkte im Zeitraum");
-    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
-    svg.setAttribute("class", "metric-sparkline");
-    svg.setAttribute("viewBox", "0 0 100 32");
-    svg.setAttribute("role", "img");
-    svg.setAttribute("aria-label", `${label}: ${rows.length} dokumentierte Trendpunkte; Lücken werden nicht verbunden.`);
-    const values = rows.map((row) => Number(row.value));
-    const minimum = Math.min(...values);
-    const maximum = Math.max(...values);
-    const range = maximum - minimum || 1;
-    const segments = [];
-    let current = [];
-    rows.forEach((row, index) => {
-      if (index && daysBetween(rows[index - 1].date, row.date) !== 1) {
-        if (current.length) segments.push(current);
-        current = [];
-      }
-      const x = rows.length === 1 ? 50 : (index / (rows.length - 1)) * 96 + 2;
-      const y = 29 - ((Number(row.value) - minimum) / range) * 26;
-      current.push(`${x.toFixed(2)},${y.toFixed(2)}`);
+    const chart = node("div", "metric-sparkline");
+    chart.setAttribute("role", "img");
+    chart.setAttribute("aria-label", `${label}: ${rows.length} dokumentierte Trendpunkte; Lücken werden nicht verbunden.`);
+    chart.dataset.sparklineMetric = metricId;
+    return chart;
+  }
+
+  function renderSparkline(element) {
+    if (!window.HealthV5ECharts?.hasECharts?.()) return;
+    const metricId = element.dataset.sparklineMetric;
+    const metric = (bundle.metrics || []).find((item) => item.id === metricId);
+    const rows = metricSeries(metricId).filter((row) => row.value !== null && row.value !== undefined).slice(-45);
+    if (!metric || !rows.length) return;
+    const days = rows.map((row) => row.date);
+    window.HealthV5ECharts.renderLine(element, {
+      metrics: [metric],
+      rowsByMetric: new Map([[metric.id, rows]]),
+      days,
+      source: "metric_card",
+      aria: `${metric.label}: kompakter dokumentierter Verlauf; fehlende Tage bleiben Lücken.`,
     });
-    if (current.length) segments.push(current);
-    segments.forEach((points) => {
-      const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
-      line.setAttribute("class", "sparkline-segment");
-      line.setAttribute("points", points.join(" "));
-      svg.append(line);
+    const instance = window.echarts?.getInstanceByDom?.(element);
+    instance?.setOption({
+      tooltip: { show: false },
+      legend: { show: false },
+      grid: { left: 4, right: 4, top: 4, bottom: 4 },
+      xAxis: { show: false },
+      yAxis: { show: false, scale: true },
+      series: [{ symbolSize: 0, lineStyle: { width: 2 } }],
     });
-    const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
-    const finalPoint = segments[segments.length - 1].at(-1).split(",");
-    dot.setAttribute("class", "sparkline-dot");
-    dot.setAttribute("cx", finalPoint[0]);
-    dot.setAttribute("cy", finalPoint[1]);
-    dot.setAttribute("r", "2.2");
-    svg.append(dot);
-    return svg;
   }

   function renderMetricCards() {
@@ -303,11 +320,28 @@
       footer.append(node("span", "v5-badge", point?.quality === "complete" ? "vollständig" : point ? "dokumentiert" : "unbekannt"));
       footer.append(node("span", "v5-meta", coverage.expected ? `${coverage.observed}/${coverage.expected} Tage im Zeitraum` : `${coverage.observed} dokumentierte Tage`));
       card.append(footer);
+      card.tabIndex = 0;
+      card.setAttribute("role", "button");
+      card.setAttribute("aria-label", `${metric.label} im Explorer öffnen`);
+      card.addEventListener("click", () => openMetricInExplorer(metric.id));
+      card.addEventListener("keydown", (event) => {
+        if (event.key === "Enter" || event.key === " ") {
+          event.preventDefault();
+          openMetricInExplorer(metric.id);
+        }
+      });
       target.append(card);
+      const spark = card.querySelector("[data-sparkline-metric]");
+      if (spark) renderSparkline(spark);
     });
     target.dataset.renderedPeriod = activePeriod;
   }

+  function openMetricInExplorer(metricId) {
+    selectView("explorer");
+    window.healthExplorerAddMetric?.(metricId);
+  }
+
   function renderDataRecency() {
     const target = q("#header-data-status");
     if (!target) return;
@@ -378,7 +412,7 @@
       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("h3", "v5-label", "Letzter vollständig klassifizierter Histamin-Index"));
       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);
@@ -392,9 +426,147 @@
         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 || ""],
+          ["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 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 personalStatus = node("select");
+        personalStatus.name = "personal_tolerance_status";
+        [
+          ["unknown", "persönlich unbekannt"],
+          ["documented_tolerated", "dokumentiert vertragen"],
+          ["documented_not_tolerated", "dokumentiert nicht vertragen"],
+          ["unclear", "unklar dokumentiert"],
+        ].forEach(([value, label]) => {
+          const option = node("option", "", label);
+          option.value = value;
+          personalStatus.append(option);
+        });
+        const personalNote = node("input");
+        personalNote.name = "personal_tolerance_note";
+        personalNote.maxLength = 160;
+        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.readOnly = ignore;
+          personalStatus.value = composite || ignore ? "unknown" : personalStatus.value;
+          personalNote.value = composite || ignore ? "" : personalNote.value;
+        });
+        [
+          ["Entscheidung", decision],
+          ["Kanonisches Lebensmittel", canonical],
+          ["SIGHi", score],
+          ["Vertrauen", confidence],
+          ["Methode", method],
+          ["Notiz", note],
+          ["Persönliche Toleranz", personalStatus],
+          ["Toleranznotiz", personalNote],
+        ].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);
+        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() {
@@ -507,6 +679,9 @@
   }

   function destroyChart(canvas) {
+    if (window.HealthV5ECharts?.dispose) {
+      window.HealthV5ECharts.dispose(canvas);
+    }
     const existing = charts.get(canvas);
     if (existing) existing.destroy();
     charts.delete(canvas);
@@ -554,7 +729,7 @@

   function chartFor(canvasId, metricIds, mode = "raw", includeBaseline = false) {
     const canvas = document.getElementById(canvasId);
-    if (!canvas || !window.Chart) return;
+    if (!canvas) return;
     const metrics = new Map((bundle.metrics || []).map((metric) => [metric.id, metric]));
     const useExplorerCadence = canvasId === "explorer-chart";
     const rowsByMetric = new Map(metricIds.map((id) => {
@@ -611,6 +786,22 @@
         tension: 0.1
       });
     }
+    const table = document.getElementById(`${canvasId}-table`);
+    if (window.HealthV5ECharts?.hasECharts?.()) {
+      const chartMetrics = metricIds.map((id) => metrics.get(id)).filter(Boolean);
+      window.HealthV5ECharts.renderLine(canvas, {
+        metrics: chartMetrics,
+        rowsByMetric,
+        days,
+        mode,
+        includeBaseline,
+        source: canvasId,
+        aria: canvas.getAttribute("aria-label") || "Dokumentierte Werte; fehlende Tage bleiben Lücken.",
+      });
+      window.HealthV5ECharts.renderTable(table, { metrics: chartMetrics, rowsByMetric, days, mode });
+      return;
+    }
+    if (!window.Chart || canvas.tagName !== "CANVAS") return;
     const previous = charts.get(canvas);
     const config = { type: "line", data: { labels: days.map((day) => formatValue(day, "date")), datasets }, options: { responsive: true, maintainAspectRatio: false, animation: useExplorerCadence || window.matchMedia("(prefers-reduced-motion: reduce)").matches ? false : undefined, interaction: { intersect: false, mode: "index" }, plugins: { legend: { position: "bottom", labels: { font: { size: 14 } } } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 7, font: { size: 13 } } }, y: { ticks: { maxTicksLimit: 6, font: { size: 13 } } } } } };
     if (previous) {
@@ -825,7 +1016,11 @@
     renderToday();
     if (activeView === "cockpit") renderCockpit();
     if (activeView === "explorer") window.healthRange?.syncUrl?.("replace");
-    if (activeView === "nutrition") { renderNutrition(); chartFor("nutrition-chart", ["nutrition.histamine"]); }
+    if (activeView === "nutrition") {
+      renderNutrition();
+      chartFor("nutrition-chart", ["nutrition.histamine"]);
+      chartFor("nutrition-aphthae-chart", ["nutrition.histamine", "symptom.aphthae"]);
+    }
     if (activeView === "doctor") renderDoctor();…3853 tokens truncated…tr, Any]] = {}
+    for row in rows:
+        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["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:
+                score = int(row["sighi_score"])
+                target["scores"].append(score)
+                target["load"] += score
+    for meal, bucket in by_meal.items():
+        max_score = max(bucket["scores"] or [0])
+        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(
+            """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, 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),
+        )
+    max_score = max(totals["scores"] or [0])
+    label = _score_label(max_score, int(totals["unknown"]), float(totals["load"]))
+    connection.execute(
+        """INSERT OR REPLACE INTO nutrition_daily_summary_v2
+           (datum,kcal,protein_g,carb_g,fat_g,item_count,nutrient_json,histamine_score,histamine_max,histamine_unknown_count,histamine_label,updated_at)
+           VALUES(?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)""",
+        (
+            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,
+        ),
+    )
+
+
+def apply_mapping_action(payload: dict[str, Any], action_id: str) -> None:
+    if DASHBOARD_DB is None:
+        raise RuntimeError("HEALTH_DASHBOARD_DB is required")
+    action_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
+    connection = sqlite3.connect(DASHBOARD_DB)
+    connection.row_factory = sqlite3.Row
+    try:
+        with connection:
+            ensure_nutrition_mapping_schema(connection)
+            existing = connection.execute("SELECT action_hash FROM nutrition_mapping_action_log WHERE action_id=?", (action_id,)).fetchone()
+            if existing is not None:
+                if existing["action_hash"] != action_hash:
+                    raise RuntimeError("conflicting idempotency key")
+                return
+            queue = connection.execute("SELECT normalized_name,example_name FROM nutrition_review_queue WHERE status='open'").fetchall()
+            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 = 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 payload["decision"] == "composite":
+                connection.execute(
+                    """INSERT OR REPLACE INTO nutrition_composite_product_review
+                       (normalized_name,canonical_food,status,updated_at)
+                       VALUES(?,?, 'needs_ingredient_review', CURRENT_TIMESTAMP)""",
+                    (normalized, canonical),
+                )
+            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"),
+                    )
+                if payload["personal_tolerance_status"] != "unknown":
+                    connection.execute(
+                        """INSERT INTO personal_food_tolerance
+                           (canonical_food,personal_status,evidence_level,notes,updated_at)
+                           VALUES(?,?, 'user_documented', ?, CURRENT_TIMESTAMP)
+                           ON CONFLICT(canonical_food) DO UPDATE SET
+                             personal_status=excluded.personal_status,
+                             evidence_level=excluded.evidence_level,
+                             notes=excluded.notes,
+                             updated_at=CURRENT_TIMESTAMP""",
+                        (
+                            canonical,
+                            payload["personal_tolerance_status"],
+                            payload["personal_tolerance_note"] or None,
+                        ),
+                    )
+                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:
+                if payload["decision"] == "assign":
+                    recompute_nutrition_day(connection, str(day))
+                connection.execute(
+                        """INSERT OR IGNORE INTO nutrition_mapping_provenance
+                       (action_id,normalized_name,decision,alias,canonical_food,sighi_score,confidence,
+                        mapping_method,source_label,source_version,note,ingredient_review_required,
+                        personal_tolerance_status,personal_tolerance_note,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["personal_tolerance_status"], payload["personal_tolerance_note"] or None,
+                        HISTAMINE_DEFINITION_VERSION, str(day),
+                    ),
+                )
+            connection.execute(
+                """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_status,personal_tolerance_note,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["personal_tolerance_status"],
+                    payload["personal_tolerance_note"] or None, HISTAMINE_DEFINITION_VERSION,
+                ),
+            )
+    finally:
+        connection.close()
+
+
+def apply_action(payload: dict[str, Any] | str, action_id: str | dict[str, int], notes: str | None = None) -> None:
+    if isinstance(payload, str):
+        if not isinstance(action_id, dict) or notes is None:
+            raise ValueError("invalid legacy symptom action")
+        normalized_payload: dict[str, Any] = {
+            "action": "symptom_checkin",
+            "date": payload,
+            "scores": action_id,
+            "notes": notes,
+        }
+        normalized_action_id = "legacy-symptom-action"
+    else:
+        normalized_payload = payload
+        if not isinstance(action_id, str):
+            raise ValueError("invalid action id")
+        normalized_action_id = action_id
+    if normalized_payload["action"] == "symptom_checkin":
+        apply_symptom_action(normalized_payload["date"], normalized_payload["scores"], normalized_payload["notes"])
+    elif normalized_payload["action"] == "nutrition_mapping":
+        apply_mapping_action(normalized_payload, normalized_action_id)
+    else:
+        raise ValueError("unsupported action")
     if DASHBOARD_V5_FILE is not None:
         v5_command = [
             sys.executable,
@@ -173,7 +642,7 @@ def apply_action(day: str, scores: dict[str, int], notes: str) -> None:
             local_today().isoformat(),
         ]
         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(
             v5_command,
             check=True,
@@ -191,8 +660,8 @@ def main() -> int:
     failures = 0
     for path in sorted(ACTION_INBOX.glob("*.json")):
         try:
-            day, scores, notes = load_action(path)
-            apply_action(day, scores, notes)
+            payload = load_action(path)
+            apply_action(payload, path.stem)
             path.unlink()
         except Exception:
             failures += 1
diff --git a/scripts/health/health_dashboard_server.py b/scripts/health/health_dashboard_server.py
index af45afc..3690e7a 100644
--- a/scripts/health/health_dashboard_server.py
+++ b/scripts/health/health_dashboard_server.py
@@ -79,6 +79,10 @@ ASSET_ROUTES = {
         "dashboard-v5-api-explorer.js",
         "text/javascript; charset=utf-8",
     ),
+    "/health-assets/dashboard-v5-echarts.js": (
+        "dashboard-v5-echarts.js",
+        "text/javascript; charset=utf-8",
+    ),
     "/health-assets/dashboard-v5-day-controller.js": (
         "dashboard-v5-day-controller.js",
         "text/javascript; charset=utf-8",
@@ -102,6 +106,7 @@ ASSET_ROUTES = {
     ),
 }
 CHECKIN_ROUTE = "/health-actions/symptom-checkin"
+NUTRITION_MAPPING_ROUTE = "/health-actions/nutrition-mapping"
 ACTION_INBOX = Path(
     os.environ.get(
         "HEALTH_DASHBOARD_ACTION_INBOX",
@@ -128,6 +133,16 @@ if TEST_INSTANCE_ID:
             "synthetic test instance requires an action inbox below /tmp"
         )
 SYMPTOM_FIELDS = ("aphthen", "gi", "fatigue", "skin", "eyes", "joints", "vascular")
+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"}
+PERSONAL_TOLERANCE_STATUSES = {
+    "unknown",
+    "documented_tolerated",
+    "documented_not_tolerated",
+    "unclear",
+}
 CSRF_TTL_SECONDS = max(
     60, min(int(os.environ.get("HEALTH_DASHBOARD_CSRF_TTL", "900")), 3600)
 )
@@ -492,6 +507,91 @@ def validate_symptom_submission(
     return day, scores, notes, return_to

+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",
+        "confidence",
+        "mapping_method",
+        "source_label",
+        "source_version",
+        "note",
+        "ingredient_review_required",
+        "personal_tolerance_status",
+        "personal_tolerance_note",
+        "return_to",
+    }
+    if set(form) != required 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]
+    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_status = form["personal_tolerance_status"][0]
+    tolerance_note = " ".join(form["personal_tolerance_note"][0].split())
+    if not re.fullmatch(r"[a-z0-9_]{1,80}", queue_key):
+        raise ValueError("invalid queue key")
+    for value, maximum in (
+        (alias, 160),
+        (canonical_food, 120),
+        (source_label, 80),
+        (source_version, 40),
+        (note, 300),
+        (tolerance_note, 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"}
+        or tolerance_status not in PERSONAL_TOLERANCE_STATUSES
+    ):
+        raise ValueError("invalid mapping value")
+    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": "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_status": tolerance_status,
+        "personal_tolerance_note": tolerance_note,
+    }, return_to
+
+
 def ensure_private_inbox() -> None:
     ACTION_INBOX.mkdir(mode=0o700, parents=True, exist_ok=True)
     metadata = ACTION_INBOX.lstat()
@@ -500,7 +600,7 @@ def ensure_private_inbox() -> None:
     os.chmod(ACTION_INBOX, 0o700)

-def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
+def write_action_payload(payload: dict[str, object]) -> None:
     """Atomically enqueue a validated action; the network server never writes the DB."""
     with _action_lock:
         ensure_private_inbox()
@@ -509,21 +609,15 @@ def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
         token = secrets.token_hex(16)
         temporary = ACTION_INBOX / f".{token}.tmp"
         destination = ACTION_INBOX / f"{token}.json"
-        payload = json.dumps(
-            {
-                "version": 1,
-                "action": "symptom_checkin",
-                "date": day,
-                "scores": scores,
-                "notes": notes,
-            },
+        packed = json.dumps(
+            payload,
             ensure_ascii=False,
             separators=(",", ":"),
         ).encode("utf-8")
         descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
         try:
             with os.fdopen(descriptor, "wb") as handle:
-                handle.write(payload)
+                handle.write(packed)
                 handle.flush()
                 os.fsync(handle.fileno())
             temporary.replace(destination)
@@ -537,6 +631,18 @@ def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
             raise

+def write_symptom_checkin(day: str, scores: dict[str, int], notes: str) -> None:
+    write_action_payload(
+        {
+            "version": 1,
+            "action": "symptom_checkin",
+            "date": day,
+            "scores": scores,
+            "notes": notes,
+        }
+    )
+
+
 class Handler(BaseHTTPRequestHandler):
     def send_error(
         self,
@@ -607,7 +713,8 @@ class Handler(BaseHTTPRequestHandler):
             else:
                 self._send_api_error(405, "read_only_endpoint", True)
             return
-        if urlparse(self.path).path != CHECKIN_ROUTE:
+        action_path = urlparse(self.path).path
+        if action_path not in {CHECKIN_ROUTE, NUTRITION_MAPPING_ROUTE}:
             self.send_error(404)
             return
         origin = self.headers.get("Origin", "")
@@ -631,6 +738,7 @@ class Handler(BaseHTTPRequestHandler):
                 self.rfile.read(length).decode("utf-8"),
                 keep_blank_values=True,
                 strict_parsing=True,
+                max_num_fields=20,
             )
         except (UnicodeError, ValueError):
             self.send_error(400)
@@ -649,8 +757,12 @@ class Handler(BaseHTTPRequestHandler):
             self.send_error(403)
             return
         try:
-            day, scores, notes, return_to = validate_symptom_submission(form)
-            write_symptom_checkin(day, scores, notes)
+            if action_path == CHECKIN_ROUTE:
+                day, scores, notes, return_to = validate_symptom_submission(form)
+                write_symptom_checkin(day, scores, notes)
+            else:
+                payload, return_to = validate_mapping_submission(form)
+                write_action_payload(payload)
         except ValueError:
             self.send_error(400)
             return


Output:
Chunk ID: 521076
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 196
Output:
 M database/schema.sql
 M scripts/health/assets/health-assets/dashboard-v5-api-explorer.js
 M scripts/health/assets/health-assets/dashboard-v5.css
 M scripts/health/assets/health-assets/dashboard-v5.js
 M scripts/health/dashboard_v5/contracts.py
 M scripts/health/dashboard_v5/data_provider.py
 M scripts/health/dashboard_v5/metric_catalog_v2.py
 M scripts/health/dashboard_v5/read_api.py
 M scripts/health/dashboard_v5/render.py
 M scripts/health/health_dashboard_action_worker.py
 M scripts/health/health_dashboard_server.py
 M tests/fixtures/dashboard_v5_fixture.py
?? docs/adr/0003-dietary-inflammation-model.md
?? scripts/health/assets/health-assets/dashboard-v5-echarts.js
?? tests/browser/dashboard_v5_sprint6g_echarts.spec.js
?? tests/test_dashboard_v5_sprint6g_nutrition.py


Ruff failed on an import-order issue in the focused test file. I’m fixing that local static issue and rerunning only the targeted checks.


Input:
*** Begin Patch
*** Update File: tests/test_dashboard_v5_sprint6g_nutrition.py
@@
 import json
 import sqlite3
 import importlib.util
+import sys
 from datetime import date
 from pathlib import Path
@@
 ROOT = Path(__file__).resolve().parents[1]

-import sys
-
 sys.path.insert(0, str(ROOT / "scripts" / "health"))
*** End Patch


Success. Updated the following files:
M tests/test_dashboard_v5_sprint6g_nutrition.py

@@ -5,2 +5,3 @@
 import importlib.util
+import sys
 from datetime import date
@@ -11,4 +12,2 @@
 ROOT = Path(__file__).resolve().parents[1]
-
-import sys


Output:
Exit code: 0
Wall time: 0.1 seconds
Output:
Success. Updated the following files:
M tests/test_dashboard_v5_sprint6g_nutrition.py


The import-order fix is in. I’m rerunning the focused tests and targeted static checks one final time.


node --check scripts/health/assets/health-assets/dashboard-v5.js && git diff --check


Output:
Chunk ID: 3b05df
Wall time: 14.5547 seconds
Process exited with code 0
Original token count: 25
Output:
........                                                                 [100%]
8 passed in 14.33s


Output:
Chunk ID: 8646b7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 5
Output:
All checks passed!


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


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


Implemented the targeted repair only.

Changed:
- Replaced `personal_tolerance` with exact `personal_tolerance_status` allowlist plus bounded `personal_tolerance_note`.
- Kept SIGHi mapping provenance/source classification separate from `personal_food_tolerance`.
- Worker now upserts `personal_food_tolerance` only for `assign` + canonical food + non-`unknown` status, with `evidence_level='user_documented'`.
- `unknown`, `composite`, and `ignore` do not create/change personal tolerance rows.
- Updated server validation, UI controls, worker payload/action log/provenance schema, `schema.sql`, fixture-focused tests, and ADR inventory counts.
- Added fixture backup/restore migration test with double schema bootstrap, `integrity_check`, restore, and raw table equality.

Validation run:
- `python -m pytest tests/test_dashboard_v5_sprint6g_nutrition.py -q` -> `8 passed`
- `python -m ruff check scripts/health/health_dashboard_action_worker.py scripts/health/health_dashboard_server.py tests/test_dashboard_v5_sprint6g_nutrition.py` -> passed
- `python -m py_compile ...` -> passed
- `node --check scripts/health/assets/health-assets/dashboard-v5.js` -> passed
- `git diff --check` -> passed

No commit, push, deploy, or production access performed.