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:
co-authored by
Claude Sonnet 5
parent
cda2c32bd0
commit
2e4fb43f09
@@ -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,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user