fix: localize the primary data-quality evidence summary (live-caught on Unraid)

Live validation on the deployed fix branch caught a real bug: every data-quality
issue's top-of-page "Evidence summary" line rendered the raw, always-English legacy
evidence.summary string unconditionally -- in all three languages -- even though the
backend has been emitting structured, localizable evidence.signals for a while
(app/services/data_quality.py already documented this exact intent). The frontend
side of that conversion was never finished.

- DataQualityIssueDetail.tsx now renders evidence.signals through the operator's
  locale as the primary summary; the raw evidence.summary string is only visible
  inside "Technical details" (via the existing EvidenceDisclosure JSON dump).
- The four DQ-DEMO-* seed rows that anchor the guided demo's scripted scenarios now
  carry real, accurate signals computed at seed time (duplicate-customer's similarity
  score is the actual SequenceMatcher ratio on the seeded names, not invented) instead
  of only a legacy English sentence.
- Rows with no structured signals (generic filler seed data) fall back to the raw
  text rather than showing a blank summary; the one known placeholder string gets its
  own localized rendering so it never displays as English filler either.
- New regression test: the vehicle_status_conflict evidence summary must show
  localized text and must never contain the specific raw English sentence that was
  live-visible before this fix, in all 3 languages.

151 backend tests, Ruff, mypy green; full local Playwright suite green (a couple of
sequential-run-only flakes, both confirmed to pass in isolation and unrelated to this
change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-04 00:44:06 +02:00
co-authored by Claude Sonnet 5
parent cda2c32bd0
commit 2e4fb43f09
7 changed files with 188 additions and 14 deletions
+38 -4
View File
@@ -4,6 +4,7 @@ import csv
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from sqlalchemy import delete, insert, update from sqlalchemy import delete, insert, update
@@ -108,11 +109,11 @@ def load_seed(db: Session) -> SeedResult:
customer_id_by_ref: dict[str, uuid.UUID] = {} customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = [] customer_rows = []
customer_row_by_ref: dict[str, dict] = {}
for row in _read_csv("customers.csv"): for row in _read_csv("customers.csv"):
cid = uuid.uuid4() cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append( customer_row = {
{
"id": cid, "id": cid,
"public_ref": row["public_ref"], "public_ref": row["public_ref"],
"first_name": row["first_name"], "first_name": row["first_name"],
@@ -122,7 +123,8 @@ def load_seed(db: Session) -> SeedResult:
"postal_code": row["postal_code"] or None, "postal_code": row["postal_code"] or None,
"city": row["city"] or None, "city": row["city"] or None,
} }
) customer_rows.append(customer_row)
customer_row_by_ref[row["public_ref"]] = customer_row
db.execute(insert(Customer), customer_rows) db.execute(insert(Customer), customer_rows)
counts["customers"] = len(customer_rows) counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first. # Second pass for merged_into (self-referencing FK) since target must exist first.
@@ -223,11 +225,42 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref] return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref] return "vehicle", vehicle_id_by_ref[entity_ref]
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
# they carry real, accurate structured signals (not just a legacy English
# sentence) -- the frontend renders these as the primary, localized evidence;
# see docs/fleet-ops-correction/current-gap-audit.md §6.
if public_ref == "DQ-DEMO-DUPLICATE":
a = customer_row_by_ref[entity_ref]
b = customer_row_by_ref[related_refs[0]]
name_a = f"{a['first_name']} {a['last_name']}".strip().lower()
name_b = f"{b['first_name']} {b['last_name']}".strip().lower()
ratio = SequenceMatcher(None, name_a, name_b).ratio()
return [
{"code": "duplicate.exact_email"},
{"code": "duplicate.exact_phone"},
{"code": "duplicate.same_postal_code"},
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}},
]
if public_ref == "DQ-DEMO-OVERLAP":
return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}]
if public_ref == "DQ-DEMO-STATUS":
return [{"code": "vehicle.booking_conflict"}]
if public_ref == "DQ-DEMO-ATTENTION":
return [
{
"code": "attention.upcoming_booking_missing_inspection",
"params": {"booking_ref": related_refs[0] if related_refs else ""},
}
]
return []
dq_rows = [] dq_rows = []
now = datetime.now(UTC) now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"): for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"]) entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or "" related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else []
dq_rows.append( dq_rows.append(
{ {
"id": uuid.uuid4(), "id": uuid.uuid4(),
@@ -240,7 +273,8 @@ def load_seed(db: Session) -> SeedResult:
"evidence_json": { "evidence_json": {
"summary": row["evidence"], "summary": row["evidence"],
"entity_ref": row["entity_ref"], "entity_ref": row["entity_ref"],
"related_refs": related_ref.split("|") if related_ref else [], "related_refs": related_refs,
"signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs),
}, },
"proposed_action_json": {}, "proposed_action_json": {},
"detected_at": now, "detected_at": now,
+27
View File
@@ -350,3 +350,30 @@ test.describe("route matrix (section 11F)", () => {
}); });
} }
}); });
test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => {
// The primary evidence line at the top of every issue's detail page must render the
// structured `evidence.signals` in the operator's language; the legacy English
// `evidence.summary` string is a technical fallback only, visible solely inside
// "Technical details". Live-caught: this line was unconditionally showing raw
// English ("vehicle marked available while reserved bookings conflict") in every
// language until fixed.
const cases: { lang: string; expectedText: RegExp }[] = [
{ lang: "nl-BE", expectedText: /overlappende reserveringen/i },
{ lang: "en-GB", expectedText: /overlapping bookings/i },
{ lang: "fr-BE", expectedText: /chevauchent|chevauchement/i },
];
for (const { lang, expectedText } of cases) {
test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await page.goto("/data-quality/DQ-DEMO-STATUS");
const summarySection = page.locator(".record-surface-evidence");
await expect(summarySection).toBeVisible();
await expect(summarySection).toContainText(expectedText);
await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict");
});
}
});
@@ -101,6 +101,17 @@
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet." "whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
} }
}, },
"evidence": {
"duplicate.exact_email": "Identical email address",
"duplicate.exact_phone": "Identical phone number",
"duplicate.same_postal_code": "Same postal code",
"duplicateSimilarName": "Strongly similar name (score {{score}})",
"missingField": "Missing field: {{field}}",
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.",
"seedPlaceholder": "Synthetic seed data with no further detail."
},
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Compare and merge", "heading": "Compare and merge",
"description": "Choose the canonical customer and review each conflicting field.", "description": "Choose the canonical customer and review each conflicting field.",
@@ -101,6 +101,17 @@
"whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte." "whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte."
} }
}, },
"evidence": {
"duplicate.exact_email": "Adresse e-mail identique",
"duplicate.exact_phone": "Numéro de téléphone identique",
"duplicate.same_postal_code": "Même code postal",
"duplicateSimilarName": "Nom fortement similaire (score {{score}})",
"missingField": "Champ manquant : {{field}}",
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante.",
"seedPlaceholder": "Donnée de démonstration synthétique sans autre détail."
},
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Comparer et fusionner", "heading": "Comparer et fusionner",
"description": "Choisissez le client de référence et examinez chaque champ divergent.", "description": "Choisissez le client de référence et examinez chaque champ divergent.",
@@ -101,6 +101,17 @@
"whyItMatters": "Een foutieve status kan een niet-beschikbaar voertuig boekbaar laten lijken, of een beschikbaar voertuig verbergen voor het wagenpark." "whyItMatters": "Een foutieve status kan een niet-beschikbaar voertuig boekbaar laten lijken, of een beschikbaar voertuig verbergen voor het wagenpark."
} }
}, },
"evidence": {
"duplicate.exact_email": "Identiek e-mailadres",
"duplicate.exact_phone": "Identiek telefoonnummer",
"duplicate.same_postal_code": "Dezelfde postcode",
"duplicateSimilarName": "Sterk gelijkende naam (score {{score}})",
"missingField": "Ontbrekend veld: {{field}}",
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
"odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog.",
"seedPlaceholder": "Synthetisch demogegeven zonder verder detail."
},
"duplicateCustomer": { "duplicateCustomer": {
"heading": "Vergelijken en samenvoegen", "heading": "Vergelijken en samenvoegen",
"description": "Kies de klant die behouden blijft en bekijk elk afwijkend veld.", "description": "Kies de klant die behouden blijft en bekijk elk afwijkend veld.",
+77 -1
View File
@@ -46,6 +46,79 @@ function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
); );
} }
interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
// The backend never emits prose for evidence -- only stable signal codes + raw data
// params (see app/services/data_quality.py::_open_issue). This is the one place that
// turns them into the operator's selected language; the legacy `evidence.summary`
// string is a technical fallback only, shown solely inside EvidenceDisclosure above.
// A handful of seed-only rows (generic filler, not tied to a scripted demo scenario)
// carry no structured signals -- rather than show nothing, this known placeholder
// summary gets a localized rendering. Any other un-signalled legacy text still falls
// back to the raw string (better than a blank primary evidence area), but only
// "Technical details" is meant to guarantee raw-text visibility.
const SEED_PLACEHOLDER_SUMMARY = "Synthetic deterministic seed issue";
function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
if (signals.length === 0) {
const rawSummary = String(issue.evidence.summary ?? "");
if (!rawSummary) return null;
const text =
rawSummary === SEED_PLACEHOLDER_SUMMARY
? t("detail.evidence.seedPlaceholder")
: rawSummary;
return (
<ul className="evidence-signal-list">
<li>{text}</li>
</ul>
);
}
function describe(signal: EvidenceSignal): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("detail.evidence.missingField", {
field: t(`detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`detail.evidence.${code}`, { defaultValue: code });
}
}
return (
<ul className="evidence-signal-list">
{signals.map((signal, index) => (
<li key={`${signal.code}-${index}`}>{describe(signal)}</li>
))}
</ul>
);
}
function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality"); const { t } = useTranslation("quality");
const { user } = useAuth(); const { user } = useAuth();
@@ -726,8 +799,11 @@ export function DataQualityIssueDetail() {
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid"> <section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div> <div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div> <div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
<div><dt>{t("detail.summary.evidenceSummary")}</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
</dl> </dl>
<div className="record-surface-evidence">
<dt>{t("detail.summary.evidenceSummary")}</dt>
<EvidenceSignalList issue={issue} />
</div>
<EvidenceDisclosure issue={issue} /></section> <EvidenceDisclosure issue={issue} /></section>
<RuleExplainer ruleType={issue.rule_type} /> <RuleExplainer ruleType={issue.rule_type} />
+4
View File
@@ -239,6 +239,10 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; } .tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; }
.tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); } .tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); }
.record-surface { padding: 18px; margin-bottom: 18px; } .record-surface { padding: 18px; margin-bottom: 18px; }
.record-surface-evidence { padding: 0 18px 16px; }
.record-surface-evidence dt { margin: 0 0 6px; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }
.record-surface-evidence .evidence-signal-list { margin: 0; padding-left: 18px; color: var(--ink); font-size: .82rem; font-weight: 700; line-height: 1.6; }
.record-surface-evidence .evidence-signal-list li { margin-bottom: 3px; }
.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; } .about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; }
.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; } .about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; }
.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; } .about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }