fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:
- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
11 of 15 seeded issues that text was literally "Synthetic deterministic seed
issue". AttentionItem now exposes evidence_signals (stable code + params, same
shape as the issue detail page) instead of a detail string; the frontend renders
them through a shared describeEvidenceSignal() used by both the dashboard and the
issue detail page. Every previously-placeholder seed row now cites a real,
per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
field, or a real pair of booking odometer readings) instead of invented prose.
- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
one, so "needs attention" led nowhere. Each now has a real open
missing_required_field issue backed by a genuinely blank field (no schema change,
no migration -- reuses the existing data-quality pipeline).
- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
show a localized explanation ("trip hasn't started yet" / "not yet closed").
MO-024's rented-but-service-overdue contradiction was already caught by the
vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
regression test rather than new logic.
Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3808bbe132
commit
4faac24b5a
@@ -19,6 +19,7 @@ from app.schemas import (
|
||||
AutomationRunOut,
|
||||
CurrentUser,
|
||||
DashboardOut,
|
||||
EvidenceSignalOut,
|
||||
TodayItem,
|
||||
)
|
||||
from app.services.operations import compute_metrics
|
||||
@@ -61,12 +62,21 @@ def get_dashboard(
|
||||
entity = customers_by_id.get(issue.entity_id)
|
||||
link_type = "customer"
|
||||
link_ref = entity.public_ref if entity else ""
|
||||
# The backend never emits prose for the attention queue -- only stable signal
|
||||
# codes + raw data params, exactly like the issue detail page's evidence list
|
||||
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
|
||||
# that turns these into the operator's selected language; `evidence_json["summary"]`
|
||||
# is a technical fallback only, never rendered here.
|
||||
signals = [
|
||||
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
|
||||
for s in issue.evidence_json.get("signals", [])
|
||||
]
|
||||
attention_items.append(
|
||||
AttentionItem(
|
||||
kind="quality_issue",
|
||||
severity=issue.severity,
|
||||
rule_type=issue.rule_type,
|
||||
detail=issue.evidence_json.get("summary", ""),
|
||||
evidence_signals=signals,
|
||||
link_type=link_type,
|
||||
link_ref=link_ref,
|
||||
issue_ref=issue.public_ref,
|
||||
|
||||
@@ -115,6 +115,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
return {
|
||||
"entity_type": "vehicle",
|
||||
"public_ref": vehicle.public_ref,
|
||||
"registration_number": vehicle.registration_number,
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"location": vehicle.location,
|
||||
|
||||
@@ -350,11 +350,16 @@ class DashboardMetrics(BaseModel):
|
||||
pending_or_failed_workflows: int
|
||||
|
||||
|
||||
class EvidenceSignalOut(BaseModel):
|
||||
code: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AttentionItem(BaseModel):
|
||||
kind: Literal["quality_issue", "vehicle"]
|
||||
severity: str
|
||||
rule_type: str
|
||||
detail: str
|
||||
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
|
||||
link_type: Literal["vehicle", "booking", "customer"]
|
||||
link_ref: str
|
||||
issue_ref: str | None = None
|
||||
|
||||
+109
-31
@@ -139,47 +139,49 @@ def load_seed(db: Session) -> SeedResult:
|
||||
|
||||
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
||||
vehicle_rows = []
|
||||
vehicle_row_by_ref: dict[str, dict] = {}
|
||||
for row in _read_csv("vehicles.csv"):
|
||||
vid = uuid.uuid4()
|
||||
vehicle_id_by_ref[row["public_ref"]] = vid
|
||||
vehicle_rows.append(
|
||||
{
|
||||
"id": vid,
|
||||
"public_ref": row["public_ref"],
|
||||
"make": row["make"],
|
||||
"model": row["model"],
|
||||
"model_year": int(row["model_year"]),
|
||||
"registration_number": row["registration_number"],
|
||||
"location": row["location"],
|
||||
"operational_status": row["operational_status"],
|
||||
"odometer_km": int(row["odometer_km"]),
|
||||
"next_service_km": int(row["next_service_km"]),
|
||||
"active": _parse_bool(row["active"]),
|
||||
"version": 1,
|
||||
}
|
||||
)
|
||||
vehicle_row = {
|
||||
"id": vid,
|
||||
"public_ref": row["public_ref"],
|
||||
"make": row["make"],
|
||||
"model": row["model"],
|
||||
"model_year": int(row["model_year"]),
|
||||
"registration_number": row["registration_number"],
|
||||
"location": row["location"],
|
||||
"operational_status": row["operational_status"],
|
||||
"odometer_km": int(row["odometer_km"]),
|
||||
"next_service_km": int(row["next_service_km"]),
|
||||
"active": _parse_bool(row["active"]),
|
||||
"version": 1,
|
||||
}
|
||||
vehicle_rows.append(vehicle_row)
|
||||
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
|
||||
db.execute(insert(Vehicle), vehicle_rows)
|
||||
counts["vehicles"] = len(vehicle_rows)
|
||||
|
||||
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
||||
booking_rows = []
|
||||
booking_row_by_ref: dict[str, dict] = {}
|
||||
for row in _read_csv("bookings.csv"):
|
||||
bid = uuid.uuid4()
|
||||
booking_id_by_ref[row["public_ref"]] = bid
|
||||
booking_rows.append(
|
||||
{
|
||||
"id": bid,
|
||||
"public_ref": row["public_ref"],
|
||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||
"status": row["status"],
|
||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
||||
}
|
||||
)
|
||||
booking_row = {
|
||||
"id": bid,
|
||||
"public_ref": row["public_ref"],
|
||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||
"status": row["status"],
|
||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
||||
}
|
||||
booking_rows.append(booking_row)
|
||||
booking_row_by_ref[row["public_ref"]] = booking_row
|
||||
db.execute(insert(Booking), booking_rows)
|
||||
counts["bookings"] = len(booking_rows)
|
||||
|
||||
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
|
||||
return "customer", customer_id_by_ref[entity_ref]
|
||||
return "vehicle", vehicle_id_by_ref[entity_ref]
|
||||
|
||||
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
|
||||
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
|
||||
# handful of seed-only rows below -- none of them carry an active rental or a
|
||||
# real booking conflict (verified against the fixed seed dataset), only a
|
||||
# genuinely-crossed service threshold or none at all, so those two fields are
|
||||
# the only ones that vary per vehicle.
|
||||
vehicle = vehicle_row_by_ref[vehicle_ref]
|
||||
return {
|
||||
"active_booking_refs": [],
|
||||
"overlapping_booking_pairs": [],
|
||||
"service_threshold_reached": service_threshold_reached,
|
||||
"odometer_km": vehicle["odometer_km"],
|
||||
"next_service_km": vehicle["next_service_km"],
|
||||
"open_booking_overlap_issue_ref": None,
|
||||
}
|
||||
|
||||
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
|
||||
later = booking_row_by_ref[later_ref]
|
||||
earlier = booking_row_by_ref[earlier_ref]
|
||||
return [
|
||||
{
|
||||
"code": "odometer.regression",
|
||||
"params": {
|
||||
"later_ref": later_ref,
|
||||
"later_km": later["end_odometer_km"],
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": earlier["end_odometer_km"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def _missing_field_signal(field: str) -> list[dict]:
|
||||
return [{"code": "missing_field", "params": {"field": field}}]
|
||||
|
||||
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
|
||||
# used to carry no structured signal at all -- just the placeholder summary
|
||||
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
|
||||
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
|
||||
# pair of booking odometer readings engineered into seed/bookings.csv), using the
|
||||
# exact same signal vocabulary the live scan (app.services.data_quality) already
|
||||
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
|
||||
"DQ-0005": [
|
||||
{
|
||||
"code": "vehicle.service_threshold_reached",
|
||||
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
|
||||
}
|
||||
],
|
||||
"DQ-0006": _missing_field_signal("location"),
|
||||
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
|
||||
"DQ-0008": [
|
||||
{
|
||||
"code": "vehicle.rental_ended",
|
||||
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
|
||||
}
|
||||
],
|
||||
"DQ-0009": _missing_field_signal("location"),
|
||||
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
|
||||
"DQ-0011": [
|
||||
{
|
||||
"code": "vehicle.service_threshold_reached",
|
||||
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
|
||||
}
|
||||
],
|
||||
"DQ-0012": _missing_field_signal("registration_number"),
|
||||
"DQ-0013": _missing_field_signal("location"),
|
||||
"DQ-0014": _missing_field_signal("registration_number"),
|
||||
"DQ-0015": _missing_field_signal("location"),
|
||||
"DQ-0016": _missing_field_signal("location"),
|
||||
"DQ-0017": _missing_field_signal("location"),
|
||||
"DQ-0018": _missing_field_signal("registration_number"),
|
||||
"DQ-0019": _missing_field_signal("location"),
|
||||
"DQ-0020": _missing_field_signal("location"),
|
||||
"DQ-0021": _missing_field_signal("location"),
|
||||
}
|
||||
|
||||
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
|
||||
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||
}
|
||||
]
|
||||
return []
|
||||
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
|
||||
|
||||
dq_rows = []
|
||||
now = datetime.now(UTC)
|
||||
|
||||
Reference in New Issue
Block a user