Publish curated MobilityOps source snapshot
This commit is contained in:
commit
7adc072f5e
317 files changed
+48851
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit import AuditEvent
|
||||
|
||||
|
||||
def record_audit_event(
|
||||
db: Session,
|
||||
*,
|
||||
actor_type: str,
|
||||
actor_label: str,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
actor_id: uuid.UUID | None = None,
|
||||
entity_id: uuid.UUID | None = None,
|
||||
correlation_id: uuid.UUID | None = None,
|
||||
before: dict[str, Any] | None = None,
|
||||
after: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
event = AuditEvent(
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
actor_label=actor_label,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
correlation_id=correlation_id or uuid.uuid4(),
|
||||
before_json=before,
|
||||
after_json=after,
|
||||
metadata_json=metadata,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(event)
|
||||
return event
|
||||
@@ -0,0 +1,799 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality_common import has_open_issue as _has_open_issue
|
||||
from app.services.data_quality_common import issue_due_at
|
||||
from app.services.data_quality_common import load_open_issue as _load_open_issue
|
||||
from app.services.data_quality_common import new_scan_ref as _new_scan_ref
|
||||
from app.services.data_quality_duplicate_scan import scan_duplicate_customers
|
||||
from app.services.data_quality_odometer import (
|
||||
open_odometer_regression_issue as open_odometer_regression_issue,
|
||||
)
|
||||
from app.services.data_quality_odometer import (
|
||||
resolve_odometer_regression as resolve_odometer_regression,
|
||||
)
|
||||
from app.services.vehicle_status import (
|
||||
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||
VehicleStatusRecommendation,
|
||||
compute_recommendation_token,
|
||||
evaluate_vehicle_status,
|
||||
gather_vehicle_status_facts,
|
||||
)
|
||||
|
||||
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
created: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def bump(self, rule_type: str) -> None:
|
||||
self.created[rule_type] = self.created.get(rule_type, 0) + 1
|
||||
|
||||
|
||||
def _open_issue(
|
||||
db: Session,
|
||||
scan: ScanResult,
|
||||
*,
|
||||
rule_type: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
severity: str,
|
||||
summary: str,
|
||||
entity_ref: str,
|
||||
related_refs: list[str],
|
||||
signals: list[dict] | None = None,
|
||||
evidence_extra: dict | None = None,
|
||||
) -> None:
|
||||
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Reintroduced evidence creates a new issue rather than silently reopening the old
|
||||
# one, but it stays linked to whatever decision was made last time so an operator
|
||||
# doesn't re-litigate from a blank slate.
|
||||
previous = db.scalar(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.rule_type == rule_type,
|
||||
DataQualityIssue.entity_type == entity_type,
|
||||
DataQualityIssue.entity_id == entity_id,
|
||||
DataQualityIssue.status != "open",
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
)
|
||||
# `summary` is kept as a technical-fallback string (shown only under "Technical
|
||||
# details"); `signals` is the stable, localizable structure the frontend renders as
|
||||
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
|
||||
evidence: dict = {
|
||||
"summary": summary,
|
||||
"entity_ref": entity_ref,
|
||||
"related_refs": related_refs,
|
||||
"signals": signals or [],
|
||||
}
|
||||
evidence.update(evidence_extra or {})
|
||||
if previous is not None:
|
||||
evidence["reopened_from"] = previous.public_ref
|
||||
evidence["previous_decision"] = previous.status
|
||||
|
||||
issue = DataQualityIssue(
|
||||
public_ref=_new_scan_ref("DQ-SCAN"),
|
||||
rule_type=rule_type,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
severity=severity,
|
||||
status="open",
|
||||
evidence_json=evidence,
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=issue_due_at(now, severity),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
scan.bump(rule_type)
|
||||
|
||||
|
||||
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
|
||||
# Anonymised customers have had their contact data removed on purpose; flagging
|
||||
# them as "missing required field" would only be resolvable by re-entering PII.
|
||||
for customer in db.scalars(
|
||||
select(Customer).where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
Customer.anonymized_at.is_(None),
|
||||
)
|
||||
).all():
|
||||
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)]
|
||||
if not customer.email and not customer.phone:
|
||||
missing.append("email_or_phone")
|
||||
if missing:
|
||||
_open_issue(
|
||||
db,
|
||||
scan,
|
||||
rule_type="missing_required_field",
|
||||
entity_type="customer",
|
||||
entity_id=customer.id,
|
||||
severity="low",
|
||||
summary=f"Missing: {', '.join(missing)}",
|
||||
entity_ref=customer.public_ref,
|
||||
related_refs=[],
|
||||
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||
)
|
||||
|
||||
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
|
||||
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(vehicle, f)]
|
||||
if missing:
|
||||
_open_issue(
|
||||
db,
|
||||
scan,
|
||||
rule_type="missing_required_field",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
severity="low",
|
||||
summary=f"Missing: {', '.join(missing)}",
|
||||
entity_ref=vehicle.public_ref,
|
||||
related_refs=[],
|
||||
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||
)
|
||||
|
||||
|
||||
def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
|
||||
vehicles = db.scalars(select(Vehicle)).all()
|
||||
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
||||
for booking in db.scalars(
|
||||
select(Booking).where(Booking.status.in_(["reserved", "active"]))
|
||||
).all():
|
||||
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
||||
|
||||
vehicle_by_id = {v.id: v for v in vehicles}
|
||||
for vehicle_id, bookings in bookings_by_vehicle.items():
|
||||
bookings.sort(key=lambda b: b.starts_at)
|
||||
for i, first in enumerate(bookings):
|
||||
for second in bookings[i + 1 :]:
|
||||
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||
vehicle = vehicle_by_id[vehicle_id]
|
||||
_open_issue(
|
||||
db,
|
||||
scan,
|
||||
rule_type="booking_overlap",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle_id,
|
||||
severity="high",
|
||||
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
|
||||
entity_ref=vehicle.public_ref,
|
||||
related_refs=[first.public_ref, second.public_ref],
|
||||
signals=[
|
||||
{
|
||||
"code": "overlap.reserved_bookings",
|
||||
"params": {"refs": [first.public_ref, second.public_ref]},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
||||
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
|
||||
# so detection and resolution can never structurally disagree -- see
|
||||
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
|
||||
vehicles = db.scalars(select(Vehicle)).all()
|
||||
for vehicle in vehicles:
|
||||
facts = gather_vehicle_status_facts(db, vehicle)
|
||||
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
|
||||
continue
|
||||
|
||||
signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
|
||||
summary = (
|
||||
f"Recommended status: {recommendation.recommended_status}"
|
||||
if recommendation.recommended_status
|
||||
else "Manual review required: active rental conflicts with a blocking condition"
|
||||
)
|
||||
_open_issue(
|
||||
db,
|
||||
scan,
|
||||
rule_type="vehicle_status_conflict",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
severity="high",
|
||||
summary=summary,
|
||||
entity_ref=vehicle.public_ref,
|
||||
related_refs=[
|
||||
*facts.active_booking_refs,
|
||||
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
|
||||
],
|
||||
signals=signals,
|
||||
)
|
||||
|
||||
|
||||
def _scan_odometer_regressions(
|
||||
db: Session,
|
||||
scan: ScanResult,
|
||||
*,
|
||||
actor_label: str | None,
|
||||
actor_type: str,
|
||||
) -> None:
|
||||
# Compare the chronological history with itself rather than every historical reading
|
||||
# to today's canonical value. That detects imported checkout/return/maintenance
|
||||
# regressions without flagging every legitimate older reading.
|
||||
# Runtime checkout/return/maintenance mutations take the vehicle lock before they
|
||||
# inspect or append DQ-03 evidence. Taking the same lock here makes scan-vs-command
|
||||
# check/merge atomic and prevents a partial-unique race for the open issue.
|
||||
vehicles = {
|
||||
v.id: v for v in db.scalars(select(Vehicle).order_by(Vehicle.id).with_for_update()).all()
|
||||
}
|
||||
readings_by_vehicle: dict[uuid.UUID, list[tuple[datetime, str, int, str, str | None]]] = {}
|
||||
inspections = db.scalars(select(Inspection)).all()
|
||||
return_inspection_booking_ids = {
|
||||
inspection.booking_id for inspection in inspections if inspection.type == "return"
|
||||
}
|
||||
returned_bookings = db.scalars(
|
||||
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
|
||||
).all()
|
||||
booking_ref_by_id = {booking.id: booking.public_ref for booking in returned_bookings}
|
||||
for booking in returned_bookings:
|
||||
# A real return inspection owns the actual reading timestamp. Using the booking's
|
||||
# planned ends_at as a duplicate second reading can make an early return appear to
|
||||
# go backwards after a newer real inspection. Keep booking data only as the legacy
|
||||
# import fallback when no return inspection exists.
|
||||
if booking.id in return_inspection_booking_ids:
|
||||
continue
|
||||
assert booking.end_odometer_km is not None
|
||||
readings_by_vehicle.setdefault(booking.vehicle_id, []).append(
|
||||
(
|
||||
booking.ends_at,
|
||||
booking.public_ref,
|
||||
booking.end_odometer_km,
|
||||
"booking",
|
||||
booking.public_ref,
|
||||
)
|
||||
)
|
||||
for inspection in inspections:
|
||||
readings_by_vehicle.setdefault(inspection.vehicle_id, []).append(
|
||||
(
|
||||
inspection.completed_at,
|
||||
inspection.public_ref,
|
||||
inspection.odometer_km,
|
||||
inspection.type,
|
||||
(
|
||||
booking_ref_by_id.get(inspection.booking_id)
|
||||
if inspection.type == "return"
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
for record in db.scalars(select(MaintenanceRecord)).all():
|
||||
readings_by_vehicle.setdefault(record.vehicle_id, []).append(
|
||||
(record.occurred_at, record.public_ref, record.odometer_km, "maintenance", None)
|
||||
)
|
||||
|
||||
for vehicle_id, readings in readings_by_vehicle.items():
|
||||
readings.sort(key=lambda reading: (reading[0], reading[1]))
|
||||
highest = readings[0] if readings else None
|
||||
for later in readings[1:]:
|
||||
assert highest is not None
|
||||
if later[2] < highest[2]:
|
||||
vehicle = vehicles[vehicle_id]
|
||||
had_open_issue = _has_open_issue(
|
||||
db,
|
||||
"odometer_regression",
|
||||
"vehicle",
|
||||
vehicle_id,
|
||||
)
|
||||
issue = open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=later[1],
|
||||
reading_km=later[2],
|
||||
canonical_km=highest[2],
|
||||
canonical_ref=highest[1],
|
||||
source_type=later[3],
|
||||
related_refs=list(
|
||||
dict.fromkeys(
|
||||
[highest[1], later[1], *([later[4]] if later[4] is not None else [])]
|
||||
)
|
||||
),
|
||||
correctable_booking_refs=[later[4]] if later[4] is not None else [],
|
||||
public_ref=_new_scan_ref("DQ-SCAN"),
|
||||
actor_label=actor_label,
|
||||
actor_type=actor_type,
|
||||
)
|
||||
if issue is not None and not had_open_issue:
|
||||
scan.bump("odometer_regression")
|
||||
if later[2] > highest[2]:
|
||||
highest = later
|
||||
|
||||
|
||||
def run_scan(
|
||||
db: Session,
|
||||
*,
|
||||
actor_label: str | None = None,
|
||||
actor_type: str = "user",
|
||||
commit: bool = True,
|
||||
) -> ScanResult:
|
||||
# The check-then-insert work below spans several rules. Serialise whole scans at the
|
||||
# database boundary so API and n8n triggers cannot both observe an empty condition.
|
||||
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
|
||||
scan = ScanResult()
|
||||
scan_duplicate_customers(db, scan, _open_issue)
|
||||
_scan_missing_required_fields(db, scan)
|
||||
_scan_odometer_regressions(
|
||||
db,
|
||||
scan,
|
||||
actor_label=actor_label,
|
||||
actor_type=actor_type,
|
||||
)
|
||||
_scan_booking_overlaps(db, scan)
|
||||
_scan_vehicle_status_conflicts(db, scan)
|
||||
if actor_label is not None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type=actor_type,
|
||||
actor_label=actor_label,
|
||||
action="data_quality_scan_run",
|
||||
entity_type="system",
|
||||
metadata={"created": scan.created},
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
return scan
|
||||
|
||||
|
||||
def defer_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
issue.status = "deferred"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_deferred",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
before={"status": "open"},
|
||||
after={"status": "deferred"},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
issue.status = "rejected"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_rejected",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
before={"status": "open"},
|
||||
after={"status": "rejected"},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def provide_missing_fields(
|
||||
db: Session, public_ref: str, fields: dict[str, str], actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "missing_required_field":
|
||||
raise AppError(
|
||||
"NOT_A_MISSING_FIELD_ISSUE",
|
||||
"This issue is not a missing-required-field issue.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
entity: Customer | Vehicle | None
|
||||
if issue.entity_type == "customer":
|
||||
entity = db.get(Customer, issue.entity_id)
|
||||
allowed = {*REQUIRED_CUSTOMER_FIELDS, "email", "phone"}
|
||||
elif issue.entity_type == "vehicle":
|
||||
entity = db.get(Vehicle, issue.entity_id)
|
||||
allowed = set(REQUIRED_VEHICLE_FIELDS)
|
||||
else:
|
||||
raise AppError(
|
||||
"UNSUPPORTED_ENTITY",
|
||||
f"Cannot provide fields for entity type '{issue.entity_type}'.",
|
||||
status_code=409,
|
||||
)
|
||||
if entity is None:
|
||||
raise AppError(
|
||||
"ENTITY_NOT_FOUND", "The underlying record could not be found.", status_code=404
|
||||
)
|
||||
|
||||
invalid = set(fields) - allowed
|
||||
if invalid:
|
||||
raise AppError(
|
||||
"INVALID_FIELD",
|
||||
f"Fields not permitted here: {', '.join(sorted(invalid))}.",
|
||||
status_code=422,
|
||||
)
|
||||
if not fields:
|
||||
raise AppError(
|
||||
"NO_FIELDS_PROVIDED", "At least one field must be provided.", status_code=422
|
||||
)
|
||||
|
||||
before = {f: getattr(entity, f) for f in allowed}
|
||||
for field_name, value in fields.items():
|
||||
if not value.strip():
|
||||
raise AppError("EMPTY_VALUE", f"Field '{field_name}' cannot be blank.", status_code=422)
|
||||
setattr(entity, field_name, value.strip())
|
||||
after = {f: getattr(entity, f) for f in allowed}
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_fields_provided",
|
||||
entity_type=issue.entity_type,
|
||||
entity_id=entity.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after=after,
|
||||
metadata={"issue_ref": issue.public_ref},
|
||||
)
|
||||
|
||||
if isinstance(entity, Customer):
|
||||
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(entity, f)]
|
||||
if not entity.email and not entity.phone:
|
||||
missing.append("email_or_phone")
|
||||
else:
|
||||
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(entity, f)]
|
||||
|
||||
if not missing:
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
)
|
||||
else:
|
||||
issue.evidence_json = {**issue.evidence_json, "summary": f"Missing: {', '.join(missing)}"}
|
||||
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_booking_overlap(
|
||||
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "booking_overlap":
|
||||
raise AppError(
|
||||
"NOT_AN_OVERLAP_ISSUE", "This issue is not a booking_overlap issue.", status_code=409
|
||||
)
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if booking_ref not in related_refs:
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's overlapping bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise AppError("BOOKING_NOT_FOUND", "The booking to block was not found.", status_code=404)
|
||||
if booking.status not in ("reserved", "active"):
|
||||
raise AppError(
|
||||
"BOOKING_NOT_ACTIVE",
|
||||
f"Booking is '{booking.status}'; only a reserved or active booking can be blocked.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
before = {"status": booking.status}
|
||||
booking.status = "blocked"
|
||||
|
||||
# Verify the minimal safe resolution actually removed the conflict: no two
|
||||
# reserved/active bookings for this vehicle should still overlap. The session has
|
||||
# autoflush disabled, so exclude the just-blocked booking by id rather than relying
|
||||
# on the in-memory status change being visible to this query.
|
||||
remaining = db.scalars(
|
||||
select(Booking).where(
|
||||
Booking.vehicle_id == booking.vehicle_id,
|
||||
Booking.status.in_(["reserved", "active"]),
|
||||
Booking.public_ref.in_(related_refs),
|
||||
Booking.id != booking.id,
|
||||
)
|
||||
).all()
|
||||
for i, first in enumerate(remaining):
|
||||
for second in remaining[i + 1 :]:
|
||||
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||
raise AppError(
|
||||
"OVERLAP_STILL_PRESENT",
|
||||
"Blocking this booking did not remove the overlap; another commitment remains.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_booking_blocked",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={"status": booking.status},
|
||||
metadata={"issue_ref": issue.public_ref, "note": note},
|
||||
)
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
|
||||
|
||||
def _load_vehicle_status_conflict_issue(
|
||||
db: Session, public_ref: str, *, lock: bool = True
|
||||
) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref, lock=lock)
|
||||
if issue.rule_type != "vehicle_status_conflict":
|
||||
raise AppError(
|
||||
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||
"This issue is not a vehicle_status_conflict issue.",
|
||||
status_code=409,
|
||||
)
|
||||
return issue
|
||||
|
||||
|
||||
def preview_vehicle_status_recommendation(
|
||||
db: Session, public_ref: str
|
||||
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
|
||||
"""Non-mutating: computes and returns the recommendation only. Never resolves the
|
||||
issue, never writes an audit event, never queues automation -- safe to call as often
|
||||
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
|
||||
issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||
token = compute_recommendation_token(vehicle, facts)
|
||||
return issue, vehicle, recommendation, token
|
||||
|
||||
|
||||
def apply_recommended_status(
|
||||
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
|
||||
) -> tuple[DataQualityIssue, str, str]:
|
||||
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
|
||||
# (or return/checkout) can't race between our fact-gathering and the write below.
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
|
||||
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||
current_token = compute_recommendation_token(vehicle, facts)
|
||||
|
||||
if current_token != expected_token:
|
||||
raise AppError(
|
||||
"RECOMMENDATION_STALE",
|
||||
"The underlying facts changed since this recommendation was shown; "
|
||||
"review the recommendation again before applying it.",
|
||||
status_code=409,
|
||||
)
|
||||
if recommendation.manual_review_required or not recommendation.safe_to_apply:
|
||||
raise AppError(
|
||||
"MANUAL_REVIEW_REQUIRED",
|
||||
"This vehicle's state requires manual review; no automatic status change is safe.",
|
||||
status_code=409,
|
||||
)
|
||||
if recommendation.recommended_status is None:
|
||||
raise AppError(
|
||||
"NO_CONFLICT_DETECTED",
|
||||
"The current vehicle state no longer conflicts; nothing to apply.",
|
||||
status_code=409,
|
||||
)
|
||||
new_status = recommendation.recommended_status
|
||||
reason_code = recommendation.recommendation_code
|
||||
|
||||
before = {"operational_status": vehicle.operational_status}
|
||||
vehicle.operational_status = new_status
|
||||
vehicle.version += 1
|
||||
|
||||
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
|
||||
# applying this change actually leaves no conflict -- never trust the pre-computed
|
||||
# recommendation alone for the post-condition.
|
||||
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||
post_check = evaluate_vehicle_status(vehicle, post_facts)
|
||||
if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
|
||||
raise AppError(
|
||||
"CONFLICT_STILL_PRESENT",
|
||||
"Applying the recommended status did not resolve the conflict.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_status_applied",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={"operational_status": vehicle.operational_status},
|
||||
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
|
||||
)
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
)
|
||||
db.commit()
|
||||
return issue, new_status, reason_code
|
||||
|
||||
|
||||
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
||||
# Mirrors the column lengths in app/models/customer.py so an override can never fail with
|
||||
# a database DataError (500) instead of a validation error.
|
||||
_MERGEABLE_FIELD_MAX_LENGTH = {
|
||||
"first_name": 80,
|
||||
"last_name": 80,
|
||||
"email": 200,
|
||||
"phone": 40,
|
||||
"postal_code": 20,
|
||||
"city": 120,
|
||||
}
|
||||
|
||||
|
||||
def merge_customers(
|
||||
db: Session,
|
||||
public_ref: str,
|
||||
survivor_ref: str,
|
||||
field_overrides: dict[str, str] | None,
|
||||
actor: CurrentUser,
|
||||
) -> dict:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "possible_duplicate_customer":
|
||||
raise AppError(
|
||||
"NOT_A_DUPLICATE_ISSUE",
|
||||
"This issue is not a possible-duplicate-customer issue.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
entity_ref = issue.evidence_json.get("entity_ref")
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
candidate_refs = {entity_ref, *related_refs}
|
||||
if survivor_ref not in candidate_refs:
|
||||
raise AppError(
|
||||
"INVALID_SURVIVOR",
|
||||
"The survivor reference must be one of the two customers in this issue.",
|
||||
status_code=422,
|
||||
details={"candidates": sorted(candidate_refs)},
|
||||
)
|
||||
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
|
||||
|
||||
# Lock both rows in a deterministic order (by public_ref) so two concurrent merges
|
||||
# touching the same customers serialise instead of deadlocking or double-merging.
|
||||
survivor = None
|
||||
loser = None
|
||||
for ref in sorted((survivor_ref, loser_ref)):
|
||||
customer = db.scalar(select(Customer).where(Customer.public_ref == ref).with_for_update())
|
||||
if ref == survivor_ref:
|
||||
survivor = customer
|
||||
else:
|
||||
loser = customer
|
||||
if survivor is None or loser is None:
|
||||
raise AppError(
|
||||
"CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404
|
||||
)
|
||||
if survivor.merged_into_customer_id is not None or loser.merged_into_customer_id is not None:
|
||||
raise AppError(
|
||||
"CUSTOMER_ALREADY_MERGED",
|
||||
"One of the customers has already been merged into another record.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
before = {
|
||||
"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS},
|
||||
"loser": {f: getattr(loser, f) for f in MERGEABLE_FIELDS},
|
||||
}
|
||||
|
||||
for field_name, value in (field_overrides or {}).items():
|
||||
if field_name not in MERGEABLE_FIELDS:
|
||||
raise AppError(
|
||||
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
|
||||
)
|
||||
cleaned = value.strip() if isinstance(value, str) else value
|
||||
max_length = _MERGEABLE_FIELD_MAX_LENGTH[field_name]
|
||||
if not cleaned or len(cleaned) > max_length:
|
||||
raise AppError(
|
||||
"INVALID_FIELD_OVERRIDE",
|
||||
f"Field '{field_name}' must be 1 to {max_length} characters.",
|
||||
status_code=422,
|
||||
)
|
||||
setattr(survivor, field_name, cleaned)
|
||||
|
||||
rewired = db.execute(
|
||||
update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id)
|
||||
)
|
||||
rewired_count: int = rewired.rowcount # type: ignore[attr-defined]
|
||||
loser.merged_into_customer_id = survivor.id
|
||||
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="customer_merged",
|
||||
entity_type="customer",
|
||||
entity_id=survivor.id,
|
||||
before=before,
|
||||
after={"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS}},
|
||||
metadata={
|
||||
"loser_ref": loser_ref,
|
||||
"survivor_ref": survivor_ref,
|
||||
"rewired_bookings": rewired_count,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"issue_ref": issue.public_ref,
|
||||
"survivor_ref": survivor_ref,
|
||||
"loser_ref": loser_ref,
|
||||
"rewired_bookings": rewired_count,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
|
||||
|
||||
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
|
||||
"""Return the local operational SLA deadline for a newly detected issue."""
|
||||
return detected_at + {
|
||||
"high": timedelta(hours=4),
|
||||
"medium": timedelta(days=1),
|
||||
"low": timedelta(days=3),
|
||||
}.get(severity, timedelta(days=1))
|
||||
|
||||
|
||||
def has_open_issue(
|
||||
db: Session,
|
||||
rule_type: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> bool:
|
||||
return (
|
||||
db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.rule_type == rule_type,
|
||||
DataQualityIssue.entity_type == entity_type,
|
||||
DataQualityIssue.entity_id == entity_id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def new_scan_ref(prefix: str) -> str:
|
||||
"""Generate a stable human-readable prefix with a concurrent-safe suffix."""
|
||||
return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
|
||||
|
||||
|
||||
def load_open_issue(db: Session, public_ref: str, *, lock: bool = True) -> DataQualityIssue:
|
||||
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||
if lock:
|
||||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||
issue = db.scalar(statement)
|
||||
if issue is None:
|
||||
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
|
||||
if issue.status != "open":
|
||||
raise AppError(
|
||||
"ISSUE_NOT_OPEN",
|
||||
f"Issue is '{issue.status}', not 'open'.",
|
||||
status_code=409,
|
||||
)
|
||||
return issue
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.customer import Customer
|
||||
|
||||
DUPLICATE_THRESHOLD = 70
|
||||
|
||||
|
||||
class ScanAccumulator(Protocol):
|
||||
def bump(self, rule_type: str) -> None: ...
|
||||
|
||||
|
||||
ScanTypeContra = TypeVar("ScanTypeContra", bound=ScanAccumulator, contravariant=True)
|
||||
|
||||
|
||||
class OpenIssue(Protocol[ScanTypeContra]):
|
||||
def __call__(
|
||||
self,
|
||||
db: Session,
|
||||
scan: ScanTypeContra,
|
||||
*,
|
||||
rule_type: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
severity: str,
|
||||
summary: str,
|
||||
entity_ref: str,
|
||||
related_refs: list[str],
|
||||
signals: list[dict] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
def _normalize(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def scan_duplicate_customers[ScanType: ScanAccumulator](
|
||||
db: Session, scan: ScanType, open_issue: OpenIssue[ScanType]
|
||||
) -> None:
|
||||
customers = list(
|
||||
db.scalars(
|
||||
select(Customer).where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
Customer.anonymized_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
customers.sort(key=lambda customer: customer.public_ref)
|
||||
|
||||
# The threshold cannot be reached without an exact email (60 points) or phone
|
||||
# (50 points). Block on normalized identifiers so this remains linear for the
|
||||
# overwhelmingly common case and only scores plausible pairs.
|
||||
candidate_pairs: set[tuple[int, int]] = set()
|
||||
for attribute in ("email", "phone"):
|
||||
blocks: dict[str, list[int]] = {}
|
||||
for index, customer in enumerate(customers):
|
||||
key = _normalize(getattr(customer, attribute))
|
||||
if key:
|
||||
blocks.setdefault(key, []).append(index)
|
||||
for indices in blocks.values():
|
||||
for offset, left in enumerate(indices):
|
||||
candidate_pairs.update((left, right) for right in indices[offset + 1 :])
|
||||
|
||||
for left, right in sorted(candidate_pairs):
|
||||
a = customers[left]
|
||||
b = customers[right]
|
||||
score = 0
|
||||
signals: list[dict] = []
|
||||
summary_parts: list[str] = []
|
||||
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
|
||||
score += 60
|
||||
signals.append({"code": "duplicate.exact_email"})
|
||||
summary_parts.append("exact email")
|
||||
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
|
||||
score += 50
|
||||
signals.append({"code": "duplicate.exact_phone"})
|
||||
summary_parts.append("exact phone")
|
||||
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
|
||||
score += 10
|
||||
signals.append({"code": "duplicate.same_postal_code"})
|
||||
summary_parts.append("exact postal code")
|
||||
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
|
||||
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
|
||||
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||
if ratio >= 0.5:
|
||||
score += round(ratio * 30)
|
||||
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
|
||||
summary_parts.append("similar name")
|
||||
|
||||
if score >= DUPLICATE_THRESHOLD:
|
||||
open_issue(
|
||||
db,
|
||||
scan,
|
||||
rule_type="possible_duplicate_customer",
|
||||
entity_type="customer",
|
||||
entity_id=a.id,
|
||||
severity="high",
|
||||
summary="; ".join(summary_parts) + f" (score {score})",
|
||||
entity_ref=a.public_ref,
|
||||
related_refs=[b.public_ref],
|
||||
signals=signals,
|
||||
)
|
||||
@@ -0,0 +1,507 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality_common import issue_due_at, load_open_issue, new_scan_ref
|
||||
|
||||
|
||||
def _odometer_fingerprint(
|
||||
*,
|
||||
source_type: str,
|
||||
later_ref: str,
|
||||
later_km: int,
|
||||
) -> dict[str, str | int]:
|
||||
"""Stable identity for one reviewed regression, independent of issue IDs."""
|
||||
return {
|
||||
"source_type": source_type,
|
||||
"later_ref": later_ref,
|
||||
"later_km": later_km,
|
||||
}
|
||||
|
||||
|
||||
def _was_odometer_regression_retained(
|
||||
db: Session,
|
||||
*,
|
||||
vehicle_id: uuid.UUID,
|
||||
fingerprint: dict[str, str | int],
|
||||
) -> bool:
|
||||
reviewed = db.scalars(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle_id,
|
||||
DataQualityIssue.status == "resolved",
|
||||
)
|
||||
).all()
|
||||
return any(
|
||||
issue.evidence_json.get("resolution_decision") == "retain_canonical"
|
||||
and fingerprint in issue.evidence_json.get("retained_odometer_fingerprints", [])
|
||||
for issue in reviewed
|
||||
)
|
||||
|
||||
|
||||
def open_odometer_regression_issue(
|
||||
db: Session,
|
||||
*,
|
||||
vehicle: Vehicle,
|
||||
reading_ref: str,
|
||||
reading_km: int,
|
||||
canonical_km: int,
|
||||
source_type: str,
|
||||
related_refs: list[str],
|
||||
correctable_booking_refs: list[str] | None = None,
|
||||
canonical_ref: str | None = None,
|
||||
detected_at: datetime | None = None,
|
||||
public_ref: str | None = None,
|
||||
actor_label: str | None = None,
|
||||
actor_type: str = "user",
|
||||
correlation_id: uuid.UUID | None = None,
|
||||
) -> DataQualityIssue | None:
|
||||
"""Open one explainable DQ-03 issue while the caller holds the vehicle lock."""
|
||||
if reading_km >= canonical_km:
|
||||
return None
|
||||
earlier_ref = canonical_ref or vehicle.public_ref
|
||||
fingerprint = _odometer_fingerprint(
|
||||
source_type=source_type,
|
||||
later_ref=reading_ref,
|
||||
later_km=reading_km,
|
||||
)
|
||||
# A manager's explicit "retain canonical" decision acknowledges this exact source
|
||||
# fact. Reopening it on every scheduled scan would create churn; only changed or new
|
||||
# evidence (and therefore a different fingerprint) is actionable again.
|
||||
if _was_odometer_regression_retained(
|
||||
db,
|
||||
vehicle_id=vehicle.id,
|
||||
fingerprint=fingerprint,
|
||||
):
|
||||
return None
|
||||
existing = db.scalar(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if existing is not None:
|
||||
before_related_refs = list(existing.evidence_json.get("related_refs", []))
|
||||
before_correctable_refs = list(existing.evidence_json.get("correctable_booking_refs", []))
|
||||
new_signal = {
|
||||
"code": "odometer.regression",
|
||||
"source_type": source_type,
|
||||
"params": {
|
||||
"later_ref": reading_ref,
|
||||
"later_km": reading_km,
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": canonical_km,
|
||||
},
|
||||
}
|
||||
existing_signals = list(existing.evidence_json.get("signals", []))
|
||||
signal_was_new = not any(
|
||||
isinstance(signal, dict)
|
||||
and isinstance(signal.get("params"), dict)
|
||||
and _odometer_fingerprint(
|
||||
source_type=str(
|
||||
signal.get("source_type", existing.evidence_json.get("source_type"))
|
||||
),
|
||||
later_ref=str(signal["params"].get("later_ref")),
|
||||
later_km=signal["params"].get("later_km"),
|
||||
)
|
||||
== fingerprint
|
||||
for signal in existing_signals
|
||||
if isinstance(signal, dict)
|
||||
and isinstance(signal.get("params"), dict)
|
||||
and isinstance(signal["params"].get("later_km"), int)
|
||||
)
|
||||
if signal_was_new:
|
||||
existing_signals.append(new_signal)
|
||||
merged_related_refs = list(
|
||||
dict.fromkeys([*before_related_refs, *(related_refs if signal_was_new else [])])
|
||||
)
|
||||
merged_correctable_refs = list(
|
||||
dict.fromkeys([*before_correctable_refs, *(correctable_booking_refs or [])])
|
||||
)
|
||||
if (
|
||||
not signal_was_new
|
||||
and merged_related_refs == before_related_refs
|
||||
and merged_correctable_refs == before_correctable_refs
|
||||
):
|
||||
return existing
|
||||
source_types = list(existing.evidence_json.get("source_types", []))
|
||||
previous_source_type = existing.evidence_json.get("source_type")
|
||||
if (
|
||||
isinstance(previous_source_type, str)
|
||||
and previous_source_type != "multiple"
|
||||
and previous_source_type not in source_types
|
||||
):
|
||||
source_types.append(previous_source_type)
|
||||
if source_type not in source_types:
|
||||
source_types.append(source_type)
|
||||
existing.evidence_json = {
|
||||
**existing.evidence_json,
|
||||
"summary": (
|
||||
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
|
||||
f"the canonical {canonical_km} km for {vehicle.public_ref}."
|
||||
),
|
||||
"related_refs": merged_related_refs,
|
||||
"correctable_booking_refs": merged_correctable_refs,
|
||||
"source_type": source_type if len(source_types) == 1 else "multiple",
|
||||
"source_types": source_types,
|
||||
"signals": existing_signals,
|
||||
}
|
||||
if actor_label is not None and (
|
||||
merged_related_refs != before_related_refs
|
||||
or merged_correctable_refs != before_correctable_refs
|
||||
or signal_was_new
|
||||
):
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type=actor_type,
|
||||
actor_label=actor_label,
|
||||
action="data_quality_issue_evidence_updated",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=existing.id,
|
||||
correlation_id=correlation_id,
|
||||
before={
|
||||
"related_refs": before_related_refs,
|
||||
"correctable_booking_refs": before_correctable_refs,
|
||||
},
|
||||
after={
|
||||
"related_refs": merged_related_refs,
|
||||
"correctable_booking_refs": merged_correctable_refs,
|
||||
},
|
||||
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
|
||||
)
|
||||
return existing
|
||||
now = detected_at or datetime.now(UTC)
|
||||
previous = db.scalar(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.rule_type == "odometer_regression",
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status != "open",
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
)
|
||||
evidence = {
|
||||
"summary": (
|
||||
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
|
||||
f"the canonical {canonical_km} km for {vehicle.public_ref}."
|
||||
),
|
||||
"entity_ref": vehicle.public_ref,
|
||||
"related_refs": related_refs,
|
||||
"correctable_booking_refs": correctable_booking_refs or [],
|
||||
"source_type": source_type,
|
||||
"source_types": [source_type],
|
||||
"signals": [
|
||||
{
|
||||
"code": "odometer.regression",
|
||||
"source_type": source_type,
|
||||
"params": {
|
||||
"later_ref": reading_ref,
|
||||
"later_km": reading_km,
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": canonical_km,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
if previous is not None:
|
||||
evidence["reopened_from"] = previous.public_ref
|
||||
evidence["previous_decision"] = previous.status
|
||||
issue = DataQualityIssue(
|
||||
public_ref=public_ref or new_scan_ref("DQ-ODO"),
|
||||
rule_type="odometer_regression",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
severity="medium",
|
||||
status="open",
|
||||
evidence_json=evidence,
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=issue_due_at(now, "medium"),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
if actor_label is not None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type=actor_type,
|
||||
actor_label=actor_label,
|
||||
action="data_quality_issue_created",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
after={"status": "open", "rule_type": "odometer_regression"},
|
||||
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
|
||||
)
|
||||
return issue
|
||||
|
||||
|
||||
def resolve_odometer_regression(
|
||||
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
|
||||
) -> DataQualityIssue:
|
||||
# Read the routing data without a row lock first. The canonical mutation order is
|
||||
# booking -> vehicle -> issue -> inspection everywhere, matching checkout/return.
|
||||
# Locking the issue before the booking creates a resolver-vs-return deadlock.
|
||||
issue_snapshot = load_open_issue(db, public_ref, lock=False)
|
||||
if issue_snapshot.rule_type != "odometer_regression":
|
||||
raise AppError(
|
||||
"NOT_AN_ODOMETER_ISSUE",
|
||||
"This issue is not an odometer_regression issue.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
def correctable_refs(issue: DataQualityIssue) -> list[str]:
|
||||
if "correctable_booking_refs" in issue.evidence_json:
|
||||
refs = issue.evidence_json.get("correctable_booking_refs", [])
|
||||
else:
|
||||
# Backward compatibility for issues created before source-aware evidence.
|
||||
refs = [
|
||||
ref
|
||||
for ref in issue.evidence_json.get("related_refs", [])
|
||||
if isinstance(ref, str) and ref.startswith("BK-")
|
||||
]
|
||||
return [ref for ref in refs if isinstance(ref, str)]
|
||||
|
||||
booking: Booking | None = None
|
||||
if body.decision != "retain_canonical":
|
||||
if body.booking_ref not in correctable_refs(issue_snapshot):
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
if body.corrected_odometer_km is None:
|
||||
raise AppError(
|
||||
"CORRECTED_VALUE_REQUIRED",
|
||||
"corrected_odometer_km is required when correcting a reading.",
|
||||
status_code=422,
|
||||
)
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||
)
|
||||
if booking is None:
|
||||
raise AppError(
|
||||
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||
)
|
||||
vehicle = db.scalar(
|
||||
select(Vehicle).where(Vehicle.id == issue_snapshot.entity_id).with_for_update()
|
||||
)
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||
)
|
||||
|
||||
# A return or scan may have appended evidence while we waited for the domain locks.
|
||||
# Lock and refresh the issue only now, then revalidate every decision against that
|
||||
# current evidence instead of resolving a stale snapshot.
|
||||
issue = load_open_issue(db, public_ref)
|
||||
if issue.rule_type != "odometer_regression" or issue.entity_id != vehicle.id:
|
||||
raise AppError(
|
||||
"ISSUE_CHANGED",
|
||||
"The issue changed while the correction was being prepared. Review it again.",
|
||||
status_code=409,
|
||||
)
|
||||
if booking is not None:
|
||||
if booking.vehicle_id != vehicle.id or booking.public_ref not in correctable_refs(issue):
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_REFERENCE",
|
||||
"booking_ref must be one of this issue's related bookings.",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
|
||||
if body.decision == "retain_canonical":
|
||||
retained_fingerprints: list[dict[str, str | int]] = []
|
||||
fallback_source_type = str(issue.evidence_json.get("source_type", "unknown"))
|
||||
for signal in issue.evidence_json.get("signals", []):
|
||||
if not isinstance(signal, dict) or signal.get("code") != "odometer.regression":
|
||||
continue
|
||||
params = signal.get("params")
|
||||
if not isinstance(params, dict):
|
||||
continue
|
||||
source_type = signal.get("source_type", fallback_source_type)
|
||||
later_ref = params.get("later_ref")
|
||||
later_km = params.get("later_km")
|
||||
earlier_ref = params.get("earlier_ref")
|
||||
earlier_km = params.get("earlier_km")
|
||||
if (
|
||||
isinstance(source_type, str)
|
||||
and isinstance(later_ref, str)
|
||||
and isinstance(later_km, int)
|
||||
and isinstance(earlier_ref, str)
|
||||
and isinstance(earlier_km, int)
|
||||
):
|
||||
retained_fingerprints.append(
|
||||
_odometer_fingerprint(
|
||||
source_type=source_type,
|
||||
later_ref=later_ref,
|
||||
later_km=later_km,
|
||||
)
|
||||
)
|
||||
issue.evidence_json = {
|
||||
**issue.evidence_json,
|
||||
"resolution_decision": "retain_canonical",
|
||||
"retained_odometer_fingerprints": retained_fingerprints,
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_retained",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
metadata={
|
||||
"issue_ref": issue.public_ref,
|
||||
"canonical_odometer_km": vehicle.odometer_km,
|
||||
"retained_fingerprint_count": len(retained_fingerprints),
|
||||
},
|
||||
)
|
||||
else:
|
||||
assert booking is not None and body.corrected_odometer_km is not None
|
||||
# Never silently lower the canonical odometer: a correction must be at or above
|
||||
# the current canonical value, otherwise it would just create a new regression.
|
||||
if body.corrected_odometer_km < vehicle.odometer_km:
|
||||
raise AppError(
|
||||
"CORRECTION_BELOW_CANONICAL",
|
||||
(
|
||||
f"Corrected value {body.corrected_odometer_km} km is still below the "
|
||||
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
# A returned booking has two persisted representations of the same reading.
|
||||
# Correct every return inspection for the selected booking, including historical
|
||||
# seed rows whose old issue evidence did not yet cite the inspection explicitly.
|
||||
related_inspections = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.booking_id == booking.id, Inspection.type == "return")
|
||||
.with_for_update()
|
||||
).all()
|
||||
before = {
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
"inspection_odometer_km": {
|
||||
inspection.public_ref: inspection.odometer_km for inspection in related_inspections
|
||||
},
|
||||
}
|
||||
booking.end_odometer_km = body.corrected_odometer_km
|
||||
for inspection in related_inspections:
|
||||
inspection.odometer_km = body.corrected_odometer_km
|
||||
vehicle.odometer_km = body.corrected_odometer_km
|
||||
vehicle.version += 1
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_odometer_corrected",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before,
|
||||
after={
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
"vehicle_odometer_km": vehicle.odometer_km,
|
||||
"inspection_odometer_km": {
|
||||
inspection.public_ref: inspection.odometer_km
|
||||
for inspection in related_inspections
|
||||
},
|
||||
},
|
||||
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
|
||||
)
|
||||
|
||||
has_remaining_evidence = False
|
||||
if booking is not None:
|
||||
target_refs = {booking.public_ref, *(item.public_ref for item in related_inspections)}
|
||||
before_related = [
|
||||
ref for ref in issue.evidence_json.get("related_refs", []) if isinstance(ref, str)
|
||||
]
|
||||
before_correctable = correctable_refs(issue)
|
||||
before_signals = [
|
||||
signal for signal in issue.evidence_json.get("signals", []) if isinstance(signal, dict)
|
||||
]
|
||||
remaining_signals = [
|
||||
signal
|
||||
for signal in before_signals
|
||||
if not (
|
||||
isinstance(signal.get("params"), dict)
|
||||
and signal["params"].get("later_ref") in target_refs
|
||||
)
|
||||
]
|
||||
remaining_related = [ref for ref in before_related if ref not in target_refs]
|
||||
remaining_correctable = [ref for ref in before_correctable if ref != booking.public_ref]
|
||||
has_remaining_evidence = bool(remaining_signals or remaining_correctable)
|
||||
issue.evidence_json = {
|
||||
**issue.evidence_json,
|
||||
"summary": (
|
||||
"Additional odometer regression evidence remains for review."
|
||||
if has_remaining_evidence
|
||||
else issue.evidence_json.get("summary", "Odometer reading corrected.")
|
||||
),
|
||||
"related_refs": remaining_related,
|
||||
"correctable_booking_refs": remaining_correctable,
|
||||
"signals": remaining_signals,
|
||||
}
|
||||
if has_remaining_evidence:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_partially_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={
|
||||
"related_refs": before_related,
|
||||
"correctable_booking_refs": before_correctable,
|
||||
"signal_count": len(before_signals),
|
||||
},
|
||||
after={
|
||||
"related_refs": remaining_related,
|
||||
"correctable_booking_refs": remaining_correctable,
|
||||
"signal_count": len(remaining_signals),
|
||||
},
|
||||
metadata={
|
||||
"decision": body.decision,
|
||||
"booking_ref": booking.public_ref,
|
||||
"note": body.note,
|
||||
},
|
||||
)
|
||||
|
||||
if not has_remaining_evidence:
|
||||
issue.status = "resolved"
|
||||
issue.resolved_at = datetime.now(UTC)
|
||||
issue.resolved_by = actor.display_name
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="data_quality_issue_resolved",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "open"},
|
||||
after={"status": "resolved"},
|
||||
metadata={"decision": body.decision, "note": body.note},
|
||||
)
|
||||
db.commit()
|
||||
return issue
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||
from app.services.knowledge import KnowledgeHealth, get_knowledge_provider
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
|
||||
|
||||
|
||||
def _knowledge_scenario_ready(health: KnowledgeHealth) -> bool:
|
||||
"""Require a reachable provider and independently verified indexed documents.
|
||||
|
||||
``source_document_count`` describes local Markdown files, while a sync report only
|
||||
describes an upload attempt. Neither proves that the active provider can retrieve a
|
||||
corpus, so an unavailable verification count remains honestly not ready.
|
||||
"""
|
||||
return bool(
|
||||
health.available and health.document_count is not None and health.document_count > 0
|
||||
)
|
||||
|
||||
|
||||
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "demo_data_seeded")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
if marker is None:
|
||||
return None, None
|
||||
metadata = marker.metadata_json or {}
|
||||
return marker.occurred_at, metadata.get("anchor_date")
|
||||
|
||||
|
||||
def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
|
||||
duplicate_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
|
||||
)
|
||||
overlap_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
|
||||
)
|
||||
failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID))
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
|
||||
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
|
||||
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
|
||||
# languages. This service only emits stable identifiers and message codes -- never
|
||||
# display prose -- per the message_code + params architecture used across the app.
|
||||
return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
|
||||
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||
automation_ready = bool(failed_run and is_demo_scenario_failure(failed_run))
|
||||
knowledge_ready = _knowledge_scenario_ready(knowledge_health)
|
||||
|
||||
return [
|
||||
DemoScenarioOut(
|
||||
id="return-anomaly",
|
||||
estimated_minutes=3,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
|
||||
ready=return_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if return_ready
|
||||
else "bookingNotFound"
|
||||
if booking is None
|
||||
else "bookingAlreadyProcessed"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="duplicate-customer",
|
||||
estimated_minutes=3,
|
||||
required_roles=["operations_manager"],
|
||||
start_path=(
|
||||
f"/data-quality/{duplicate_issue.public_ref}"
|
||||
if duplicate_issue
|
||||
else "/data-quality"
|
||||
),
|
||||
ready=duplicate_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if duplicate_ready
|
||||
else "duplicateIssueNotFound"
|
||||
if duplicate_issue is None
|
||||
else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="booking-overlap",
|
||||
estimated_minutes=2,
|
||||
required_roles=["operations_manager"],
|
||||
start_path=(
|
||||
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
|
||||
),
|
||||
ready=overlap_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if overlap_ready
|
||||
else "overlapIssueNotFound"
|
||||
if overlap_issue is None
|
||||
else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="automation-retry",
|
||||
estimated_minutes=2,
|
||||
required_roles=["operations_manager"],
|
||||
start_path="/automation",
|
||||
ready=automation_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if automation_ready
|
||||
else "failedEventNotFound"
|
||||
if failed_run is None
|
||||
else "eventAlreadyRecovered"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="knowledge-question",
|
||||
estimated_minutes=2,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path="/knowledge",
|
||||
ready=knowledge_ready,
|
||||
blocked_reason_code=None if knowledge_ready else "knowledgeUnavailable",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
n8n = derive_n8n_status(db)
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
mcp_hub = derive_mcp_hub_status(db)
|
||||
|
||||
return [
|
||||
DemoIntegrationSummaryOut(
|
||||
key="n8n",
|
||||
status_code=n8n.state,
|
||||
detail_code="n8nDetail",
|
||||
detail_params={
|
||||
"succeeded": n8n.succeeded,
|
||||
"failed": n8n.failed,
|
||||
"pending": n8n.pending,
|
||||
},
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="ragcore",
|
||||
status_code=(
|
||||
"operational"
|
||||
if knowledge_health.provider == "ragcore"
|
||||
and _knowledge_scenario_ready(knowledge_health)
|
||||
else "demoMode"
|
||||
if knowledge_health.provider == "demo"
|
||||
and _knowledge_scenario_ready(knowledge_health)
|
||||
else "unavailable"
|
||||
),
|
||||
detail_code=(
|
||||
"ragcoreDetail"
|
||||
if knowledge_health.document_count is not None
|
||||
else "ragcoreSourceDetail"
|
||||
),
|
||||
detail_params={
|
||||
"count": knowledge_health.document_count or 0,
|
||||
"source_count": knowledge_health.source_document_count,
|
||||
"collection": knowledge_health.collection,
|
||||
},
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="mcp_hub",
|
||||
# `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
|
||||
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
|
||||
# to be called. Only real recorded `mcp_tool_request` calls make this
|
||||
# "operational" -- same evidence rule the integration status page uses.
|
||||
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||
detail_code=(
|
||||
"mcpDetailOperational" if mcp_hub.state == "operational" else "mcpDetailPrepared"
|
||||
),
|
||||
detail_params={},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def scenario_integrity_report(db: Session) -> dict:
|
||||
"""Server-side scenario-integrity check run after every reset (section 15): confirms
|
||||
each of the 5 named scenarios is actually present and ready, rather than trusting the
|
||||
seed loader silently. Reuses the same readiness derivation the manifest/scenario
|
||||
overview already use, so this can never drift from what a visitor actually sees."""
|
||||
scenarios = _scenarios(db)
|
||||
not_ready = [
|
||||
{"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready
|
||||
]
|
||||
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
|
||||
|
||||
|
||||
def build_demo_manifest(db: Session) -> DemoManifestOut:
|
||||
last_reset_at, anchor_date = _last_reset(db)
|
||||
return DemoManifestOut(
|
||||
demo_mode=settings.mobilityops_demo_mode,
|
||||
organization_name=settings.demo_organization_name,
|
||||
timezone=settings.demo_timezone,
|
||||
synthetic_data=True,
|
||||
allow_reset=settings.demo_allow_reset,
|
||||
last_reset_at=last_reset_at,
|
||||
anchor_date=anchor_date,
|
||||
guide_available=True,
|
||||
required_roles=["operations_manager", "rental_employee"],
|
||||
scenarios=_scenarios(db),
|
||||
integrations=_integrations(db),
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
logger = logging.getLogger("mobilityops.dispatcher")
|
||||
settings = get_settings()
|
||||
|
||||
_stop_event = threading.Event()
|
||||
|
||||
|
||||
def _backoff_seconds(attempts: int) -> int:
|
||||
return min(2**attempts, 60)
|
||||
|
||||
|
||||
def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
|
||||
"""Recover events stuck in 'delivering' because the process that claimed them died
|
||||
before recording an outcome. Only leases whose deadline has passed are touched, so an
|
||||
in-flight delivery from a still-alive worker is never disturbed or double-processed;
|
||||
`attempts` is preserved so the count reflects true history."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
rows = db.scalars(
|
||||
select(OutboxEvent)
|
||||
.where(
|
||||
OutboxEvent.delivery_status == "delivering",
|
||||
OutboxEvent.next_attempt_at.is_not(None),
|
||||
OutboxEvent.next_attempt_at <= now,
|
||||
)
|
||||
.limit(batch_size)
|
||||
.with_for_update(skip_locked=True)
|
||||
).all()
|
||||
for row in rows:
|
||||
row.delivery_status = "pending"
|
||||
row.next_attempt_at = None
|
||||
row.last_error = (
|
||||
"Recovered from a stale 'delivering' lease "
|
||||
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
||||
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
||||
)[:2000]
|
||||
row.last_error_code = "staleLeaseRecovered"
|
||||
db.commit()
|
||||
return len(rows)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
||||
"""Claim a batch of due events with a short-lived transaction (no network I/O held open).
|
||||
Each claimed row gets a lease deadline (next_attempt_at) so a crash between this claim
|
||||
and the outcome being recorded is recoverable by _reclaim_stale_deliveries."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
rows = db.scalars(
|
||||
select(OutboxEvent)
|
||||
.where(
|
||||
OutboxEvent.delivery_status == "pending",
|
||||
(OutboxEvent.next_attempt_at.is_(None)) | (OutboxEvent.next_attempt_at <= now),
|
||||
)
|
||||
.order_by(OutboxEvent.occurred_at.asc())
|
||||
.limit(batch_size)
|
||||
.with_for_update(skip_locked=True)
|
||||
).all()
|
||||
claimed_ids = [row.event_id for row in rows]
|
||||
lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds)
|
||||
for row in rows:
|
||||
row.delivery_status = "delivering"
|
||||
row.next_attempt_at = lease_deadline
|
||||
# The token is stored inside the internal payload (the wire envelope below
|
||||
# explicitly selects only contract fields). It lets the outcome transaction
|
||||
# prove that this is still the same lease after network I/O. A stale worker
|
||||
# must never overwrite a later reclaim/retry or an idempotent callback.
|
||||
row.payload_json = {
|
||||
**row.payload_json,
|
||||
"_delivery_claim_token": str(uuid.uuid4()),
|
||||
}
|
||||
db.commit()
|
||||
return claimed_ids
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.get(OutboxEvent, event_id)
|
||||
if event is None:
|
||||
return
|
||||
# Reconstruct the wire envelope from contracts/events.schema.json: only the fields
|
||||
# the schema declares (additionalProperties: false), sourced from real columns where
|
||||
# possible. `payload_json` also carries an internal `aggregate_ref` convenience field
|
||||
# for our own dashboard/audit reads, which must not be forwarded to n8n.
|
||||
try:
|
||||
wire_event = {
|
||||
"event_id": str(event.event_id),
|
||||
"event_type": event.event_type,
|
||||
"occurred_at": event.occurred_at.isoformat(),
|
||||
"correlation_id": event.payload_json["correlation_id"],
|
||||
"aggregate": event.payload_json["aggregate"],
|
||||
"data": event.payload_json["data"],
|
||||
}
|
||||
payload_error: str | None = None
|
||||
except KeyError as exc:
|
||||
# A malformed payload must still resolve the claimed "delivering" row to a
|
||||
# terminal-or-retryable state below, rather than leaving it stuck forever.
|
||||
wire_event = None
|
||||
payload_error = f"Malformed outbox payload, missing key {exc}"
|
||||
attempts = event.attempts
|
||||
claim_token = event.payload_json.get("_delivery_claim_token")
|
||||
if event.delivery_status != "delivering" or not isinstance(claim_token, str):
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
error_code: str | None
|
||||
if wire_event is None:
|
||||
success, error, body = False, payload_error, None
|
||||
error_code = "malformedPayload"
|
||||
else:
|
||||
try:
|
||||
response = httpx.post(
|
||||
settings.n8n_webhook_url,
|
||||
json=wire_event,
|
||||
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
|
||||
timeout=settings.n8n_http_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
if isinstance(body, dict):
|
||||
acknowledged = body.get("ok") is True
|
||||
response_event_id = body.get("event_id")
|
||||
event_id_matches = response_event_id == str(event_id)
|
||||
result = body.get("result")
|
||||
execution_id = result.get("execution_id") if isinstance(result, dict) else None
|
||||
execution_id_valid = isinstance(execution_id, str) and bool(execution_id.strip())
|
||||
success = acknowledged and event_id_matches and execution_id_valid
|
||||
if success:
|
||||
error = None
|
||||
error_code = None
|
||||
elif not acknowledged:
|
||||
error = (
|
||||
"n8n response did not explicitly acknowledge the event with ok=true: "
|
||||
f"{body}"
|
||||
)
|
||||
error_code = (
|
||||
"remoteReportedFailure" if body.get("ok") is False else "malformedResponse"
|
||||
)
|
||||
elif not event_id_matches:
|
||||
error = (
|
||||
"n8n acknowledged a different event ID "
|
||||
f"(expected {event_id}, received {response_event_id!r})"
|
||||
)
|
||||
error_code = "mismatchedEventId"
|
||||
else:
|
||||
error = "n8n response omitted a valid result.execution_id"
|
||||
error_code = "malformedResponse"
|
||||
else:
|
||||
# A 2xx status with a non-object (or unparsable) body means the workflow
|
||||
# itself errored before its "Respond to Webhook" node ran -- n8n's default
|
||||
# error response still carries a 2xx-looking status here. Treat it as a
|
||||
# failure so the event is retried rather than lost or wrongly marked
|
||||
# succeeded.
|
||||
success = False
|
||||
error = (
|
||||
f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
|
||||
)
|
||||
error_code = "malformedResponse"
|
||||
except httpx.HTTPError as exc:
|
||||
success = False
|
||||
error = f"{type(exc).__name__}: {exc}"
|
||||
error_code = "connectionError"
|
||||
body = None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
|
||||
)
|
||||
if event is None:
|
||||
return
|
||||
if (
|
||||
event.delivery_status != "delivering"
|
||||
or event.attempts != attempts
|
||||
or event.payload_json.get("_delivery_claim_token") != claim_token
|
||||
):
|
||||
logger.info(
|
||||
"Ignoring stale delivery outcome for event %s because lease ownership changed",
|
||||
event_id,
|
||||
)
|
||||
return
|
||||
event.attempts = attempts + 1
|
||||
event.payload_json = {
|
||||
key: value
|
||||
for key, value in event.payload_json.items()
|
||||
if key != "_delivery_claim_token"
|
||||
}
|
||||
if success:
|
||||
event.delivery_status = "succeeded"
|
||||
event.last_error = None
|
||||
event.last_error_code = None
|
||||
event.next_attempt_at = None
|
||||
result = (body or {}).get("result")
|
||||
execution_id = result.get("execution_id") if isinstance(result, dict) else None
|
||||
event.external_run_id = execution_id if isinstance(execution_id, str) else None
|
||||
else:
|
||||
event.last_error = (error or "delivery failed")[:2000]
|
||||
event.last_error_code = error_code or "unknownError"
|
||||
if event.attempts >= settings.n8n_max_attempts:
|
||||
event.delivery_status = "failed"
|
||||
event.next_attempt_at = None
|
||||
else:
|
||||
event.delivery_status = "pending"
|
||||
event.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||
seconds=_backoff_seconds(event.attempts)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def run_dispatch_cycle() -> int:
|
||||
"""Run one reclaim+claim+deliver cycle. Returns the number of events processed."""
|
||||
_reclaim_stale_deliveries()
|
||||
claimed = _claim_due_events()
|
||||
for event_id in claimed:
|
||||
_deliver_one(event_id)
|
||||
return len(claimed)
|
||||
|
||||
|
||||
def _loop() -> None:
|
||||
while not _stop_event.is_set():
|
||||
try:
|
||||
run_dispatch_cycle()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Outbox dispatch cycle failed")
|
||||
_stop_event.wait(settings.n8n_dispatch_interval_seconds)
|
||||
|
||||
|
||||
def start_background_dispatcher() -> None:
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
return
|
||||
_stop_event.clear()
|
||||
thread = threading.Thread(target=_loop, name="outbox-dispatcher", daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
def stop_background_dispatcher() -> None:
|
||||
_stop_event.set()
|
||||
@@ -0,0 +1,343 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import Row, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||
from app.schemas import (
|
||||
McpHubIntegrationStatus,
|
||||
N8nErrorHandlerStatus,
|
||||
N8nIntegrationStatus,
|
||||
N8nWorkflowEvidence,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
_hub_health_lock = threading.Lock()
|
||||
_hub_health_cached_at = 0.0
|
||||
_hub_health_cached_value: bool | None = None
|
||||
|
||||
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
|
||||
# built (all with their full node set saved).
|
||||
_CANONICAL_WORKFLOWS = (
|
||||
"Fleet Ops — Vehicle Return Orchestration",
|
||||
"Fleet Ops — Scheduled Data Quality Scan",
|
||||
"Fleet Ops — RAGcore Procedure Sync",
|
||||
"Fleet Ops — Workflow Error Handler",
|
||||
)
|
||||
_STALE_AFTER = {
|
||||
"Fleet Ops — Scheduled Data Quality Scan": timedelta(hours=2, minutes=30),
|
||||
"Fleet Ops — RAGcore Procedure Sync": timedelta(hours=30),
|
||||
}
|
||||
|
||||
|
||||
def _latest_rows_per_workflow(db: Session, action: str) -> Sequence[Row[Any]]:
|
||||
"""Return the most recent audit row per workflow name for one action.
|
||||
|
||||
Heartbeats arrive on every scheduled run, so loading *all* rows and picking the
|
||||
latest in Python would grow linearly with deployment age. ``DISTINCT ON`` lets
|
||||
PostgreSQL return exactly one (latest) row per workflow instead.
|
||||
"""
|
||||
workflow_name = AuditEvent.after_json["workflow_name"].astext
|
||||
return db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||
.where(AuditEvent.action == action)
|
||||
.distinct(workflow_name)
|
||||
.order_by(workflow_name, AuditEvent.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
|
||||
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
# Prepared demo failures are props, not health signals. They stay visible and
|
||||
# counted -- hiding them would be its own kind of lie -- but they are counted
|
||||
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
|
||||
# "operational". Without this split the demo seed's single staged failure pins the
|
||||
# integration to "degraded" forever, which tells a viewer something untrue about
|
||||
# the automation.
|
||||
demo_scenario_failed = (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(OutboxEvent)
|
||||
.where(
|
||||
OutboxEvent.delivery_status == "failed",
|
||||
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
unexpected_failed = max(failed - demo_scenario_failed, 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
# Health talks about real failures only, so the "latest failure" a health reader
|
||||
# sees must exclude the staged one too.
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(
|
||||
OutboxEvent.delivery_status == "failed",
|
||||
or_(
|
||||
OutboxEvent.last_error_code.is_(None),
|
||||
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
|
||||
),
|
||||
)
|
||||
)
|
||||
latest_demo_scenario_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(
|
||||
OutboxEvent.delivery_status == "failed",
|
||||
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
|
||||
)
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif not settings.n8n_webhook_url:
|
||||
# Persisted history does not make a currently unconfigured dispatcher green.
|
||||
state = "unavailable"
|
||||
elif unexpected_failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif unexpected_failed > 0:
|
||||
state = "degraded"
|
||||
# Queued/in-flight work proves only that MobilityOps has work for the dispatcher;
|
||||
# it does not prove that n8n has ever accepted a delivery. A green state requires
|
||||
# at least one persisted successful round trip.
|
||||
elif succeeded > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
|
||||
# runs an operator triggered manually from the Data Quality page.
|
||||
latest_scan_at = db.scalar(
|
||||
select(func.max(AuditEvent.occurred_at)).where(
|
||||
AuditEvent.action == "data_quality_scan_run",
|
||||
AuditEvent.actor_type == "service",
|
||||
)
|
||||
)
|
||||
|
||||
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
|
||||
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
|
||||
# procedures_sync_result), the same "the workflow's own callback is the evidence"
|
||||
# pattern the scheduled scan and error handler already use below.
|
||||
latest_procedure_sync_row = db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||
.where(AuditEvent.action == "n8n_procedures_synced")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
latest_procedure_sync_at = (
|
||||
latest_procedure_sync_row[0] if latest_procedure_sync_row is not None else None
|
||||
)
|
||||
|
||||
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
|
||||
# up and firing correctly.
|
||||
total_failures_registered = (
|
||||
db.scalar(
|
||||
select(func.count(AuditEvent.id)).where(
|
||||
AuditEvent.action == "n8n_workflow_failure_registered"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
latest_failure_row = db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.after_json)
|
||||
.where(AuditEvent.action == "n8n_workflow_failure_registered")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
|
||||
latest_handler_failure_workflow = (
|
||||
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
|
||||
)
|
||||
|
||||
legacy_evidence_by_workflow = {
|
||||
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
|
||||
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
|
||||
"Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
|
||||
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||
}
|
||||
heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
|
||||
heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat")
|
||||
for occurred_at, after, metadata in heartbeat_rows:
|
||||
workflow_name = (after or {}).get("workflow_name")
|
||||
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow:
|
||||
heartbeat_by_workflow[workflow_name] = (
|
||||
occurred_at,
|
||||
(after or {}).get("status", "succeeded"),
|
||||
(metadata or {}).get("execution_id"),
|
||||
)
|
||||
|
||||
failure_by_workflow: dict[str, tuple[datetime, str | None]] = {}
|
||||
failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered")
|
||||
for occurred_at, after, metadata in failure_rows:
|
||||
workflow_name = (after or {}).get("workflow_name")
|
||||
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow:
|
||||
failure_by_workflow[workflow_name] = (
|
||||
occurred_at,
|
||||
(metadata or {}).get("execution_id"),
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
workflows: list[N8nWorkflowEvidence] = []
|
||||
for name in _CANONICAL_WORKFLOWS:
|
||||
legacy_seen = legacy_evidence_by_workflow[name]
|
||||
heartbeat = heartbeat_by_workflow.get(name)
|
||||
failure_signal = failure_by_workflow.get(name)
|
||||
seen_at = heartbeat[0] if heartbeat else legacy_seen
|
||||
last_status: Literal["succeeded", "failed"] | None = (
|
||||
"succeeded" if seen_at is not None else None
|
||||
)
|
||||
execution_id = heartbeat[2] if heartbeat else None
|
||||
if heartbeat and heartbeat[1] == "failed":
|
||||
last_status = "failed"
|
||||
if failure_signal and (seen_at is None or failure_signal[0] > seen_at):
|
||||
seen_at = failure_signal[0]
|
||||
last_status = "failed"
|
||||
execution_id = failure_signal[1]
|
||||
if name == "Fleet Ops — RAGcore Procedure Sync" and latest_procedure_sync_row:
|
||||
sync_at, sync_result, sync_metadata = latest_procedure_sync_row
|
||||
synced = (sync_result or {}).get("synced", 0)
|
||||
failed_syncs = (sync_result or {}).get("failed", 0)
|
||||
# The result callback is authoritative for corpus delivery. A generic
|
||||
# succeeded heartbeat cannot turn a zero/partial upload green.
|
||||
sync_result_failed = (
|
||||
not isinstance(synced, int)
|
||||
or not isinstance(failed_syncs, int)
|
||||
or synced <= 0
|
||||
or failed_syncs > 0
|
||||
)
|
||||
if sync_result_failed:
|
||||
last_status = "failed"
|
||||
if seen_at is None or sync_at > seen_at:
|
||||
seen_at = sync_at
|
||||
if sync_result_failed:
|
||||
execution_id = (sync_metadata or {}).get("execution_id")
|
||||
workflow_state: Literal["no_evidence", "healthy", "stale", "failed"]
|
||||
if seen_at is None:
|
||||
workflow_state = "no_evidence"
|
||||
elif last_status == "failed":
|
||||
workflow_state = "failed"
|
||||
elif name in _STALE_AFTER and now - seen_at > _STALE_AFTER[name]:
|
||||
workflow_state = "stale"
|
||||
else:
|
||||
workflow_state = "healthy"
|
||||
workflows.append(
|
||||
N8nWorkflowEvidence(
|
||||
name=name,
|
||||
built=True,
|
||||
last_seen_at=seen_at,
|
||||
state=workflow_state,
|
||||
last_status=last_status,
|
||||
last_execution_id=execution_id,
|
||||
)
|
||||
)
|
||||
|
||||
if state in {"operational", "no_evidence"} and any(
|
||||
workflow.state in {"failed", "stale"} for workflow in workflows
|
||||
):
|
||||
state = "degraded"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
unexpected_failed=unexpected_failed,
|
||||
demo_scenario_failed=demo_scenario_failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
latest_demo_scenario_at=latest_demo_scenario_at,
|
||||
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
|
||||
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
|
||||
workflows=workflows,
|
||||
error_handler=N8nErrorHandlerStatus(
|
||||
total_failures_registered=total_failures_registered,
|
||||
latest_failure_at=latest_handler_failure_at,
|
||||
latest_failure_workflow=latest_handler_failure_workflow,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
|
||||
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
|
||||
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
|
||||
total_calls = (
|
||||
db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
|
||||
or 0
|
||||
)
|
||||
latest_call_row = db.execute(
|
||||
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
|
||||
.where(AuditEvent.action == "mcp_tool_request")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
last_called_at = latest_call_row[0] if latest_call_row else None
|
||||
last_client = latest_call_row[1] if latest_call_row else None
|
||||
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
|
||||
|
||||
hub_reachable = _check_hub_reachable()
|
||||
|
||||
state: Literal["not_configured", "no_evidence", "operational"]
|
||||
if not settings.mcp_hub_registration_enabled:
|
||||
state = "not_configured"
|
||||
elif total_calls > 0 and hub_reachable is not False:
|
||||
state = "operational"
|
||||
else:
|
||||
# Historical tool calls remain useful telemetry, but cannot support a current
|
||||
# operational claim when the configured Hub health endpoint is unreachable.
|
||||
# ``hub_reachable`` stays available separately so consumers can distinguish
|
||||
# this from a provider that simply has no call evidence yet.
|
||||
state = "no_evidence"
|
||||
|
||||
return McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state=state,
|
||||
total_calls=total_calls,
|
||||
last_tool=last_tool,
|
||||
last_client=last_client,
|
||||
last_called_at=last_called_at,
|
||||
hub_reachable=hub_reachable,
|
||||
)
|
||||
|
||||
|
||||
def _check_hub_reachable() -> bool | None:
|
||||
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
|
||||
its side, so this is the only thing Fleet Ops itself can honestly check).
|
||||
`None` means not configured / not checked, never a guess."""
|
||||
if not settings.mcp_hub_base_url:
|
||||
return None
|
||||
global _hub_health_cached_at, _hub_health_cached_value
|
||||
now = time.monotonic()
|
||||
with _hub_health_lock:
|
||||
if now - _hub_health_cached_at < settings.mcp_hub_health_cache_seconds:
|
||||
return _hub_health_cached_value
|
||||
try:
|
||||
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
|
||||
_hub_health_cached_value = response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
_hub_health_cached_value = False
|
||||
_hub_health_cached_at = time.monotonic()
|
||||
return _hub_health_cached_value
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
EvidenceState = Literal["grounded", "insufficient", "unavailable"]
|
||||
KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"]
|
||||
|
||||
|
||||
class SourceCard(BaseModel):
|
||||
document_id: str
|
||||
title: str
|
||||
version: str
|
||||
section: str
|
||||
excerpt: str
|
||||
|
||||
|
||||
class GroundedAnswer(BaseModel):
|
||||
answer: str
|
||||
evidence_state: EvidenceState
|
||||
sources: list[SourceCard]
|
||||
provider: str
|
||||
correlation_id: str
|
||||
|
||||
|
||||
class KnowledgeHealth(BaseModel):
|
||||
provider: str
|
||||
available: bool
|
||||
detail: str
|
||||
tenant: str
|
||||
workspace: str
|
||||
collection: str
|
||||
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
|
||||
# unknown, never "zero procedures".
|
||||
document_count: int | None
|
||||
source_document_count: int
|
||||
reported_synced_document_count: int | None
|
||||
reported_failed_document_count: int | None
|
||||
last_sync_at: datetime | None
|
||||
statistics_state: KnowledgeStatisticsState
|
||||
|
||||
|
||||
class KnowledgeProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
|
||||
|
||||
def ask(
|
||||
self, question: str, correlation_id: str, language: str = "en-GB"
|
||||
) -> GroundedAnswer: ...
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_knowledge_provider() -> KnowledgeProvider:
|
||||
settings = get_settings()
|
||||
if settings.knowledge_provider == "ragcore":
|
||||
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
|
||||
|
||||
return RAGcoreKnowledgeProvider()
|
||||
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||
|
||||
return DemoKnowledgeProvider()
|
||||
@@ -0,0 +1,413 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
from app.services.knowledge.procedures import parse_frontmatter
|
||||
|
||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||
DEFAULT_LANGUAGE = "en-GB"
|
||||
|
||||
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
||||
"en-GB": {
|
||||
"a",
|
||||
"an",
|
||||
"the",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"to",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"for",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"if",
|
||||
"then",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"must",
|
||||
"may",
|
||||
"can",
|
||||
"could",
|
||||
"should",
|
||||
"would",
|
||||
"i",
|
||||
"you",
|
||||
"it",
|
||||
"we",
|
||||
"they",
|
||||
"my",
|
||||
"your",
|
||||
"what",
|
||||
"when",
|
||||
"how",
|
||||
"with",
|
||||
"without",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"not",
|
||||
"no",
|
||||
},
|
||||
"nl-BE": {
|
||||
"een",
|
||||
"de",
|
||||
"het",
|
||||
"is",
|
||||
"zijn",
|
||||
"was",
|
||||
"waren",
|
||||
"worden",
|
||||
"wordt",
|
||||
"van",
|
||||
"in",
|
||||
"op",
|
||||
"voor",
|
||||
"en",
|
||||
"of",
|
||||
"maar",
|
||||
"als",
|
||||
"dan",
|
||||
"moet",
|
||||
"mag",
|
||||
"kan",
|
||||
"kunnen",
|
||||
"zou",
|
||||
"zouden",
|
||||
"ik",
|
||||
"jij",
|
||||
"u",
|
||||
"we",
|
||||
"wij",
|
||||
"zij",
|
||||
"mijn",
|
||||
"jouw",
|
||||
"wat",
|
||||
"wanneer",
|
||||
"hoe",
|
||||
"met",
|
||||
"zonder",
|
||||
"dit",
|
||||
"dat",
|
||||
"deze",
|
||||
"die",
|
||||
"niet",
|
||||
"geen",
|
||||
},
|
||||
"fr-BE": {
|
||||
"un",
|
||||
"une",
|
||||
"le",
|
||||
"la",
|
||||
"les",
|
||||
"des",
|
||||
"est",
|
||||
"sont",
|
||||
"était",
|
||||
"être",
|
||||
"de",
|
||||
"du",
|
||||
"en",
|
||||
"sur",
|
||||
"pour",
|
||||
"et",
|
||||
"ou",
|
||||
"mais",
|
||||
"si",
|
||||
"alors",
|
||||
"doit",
|
||||
"peut",
|
||||
"peuvent",
|
||||
"pourrait",
|
||||
"devrait",
|
||||
"je",
|
||||
"tu",
|
||||
"vous",
|
||||
"il",
|
||||
"elle",
|
||||
"nous",
|
||||
"ils",
|
||||
"mon",
|
||||
"votre",
|
||||
"quoi",
|
||||
"quand",
|
||||
"comment",
|
||||
"avec",
|
||||
"sans",
|
||||
"ce",
|
||||
"cette",
|
||||
"ces",
|
||||
"cela",
|
||||
"pas",
|
||||
"non",
|
||||
},
|
||||
}
|
||||
|
||||
# Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with
|
||||
# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of
|
||||
# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently
|
||||
# drops every accent and fragments the word either side of it.
|
||||
_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+")
|
||||
|
||||
|
||||
def _stem(word: str) -> str:
|
||||
# Deterministic, intentionally crude suffix stripping — good enough to match "returns"
|
||||
# with "return" or "damaged" with "damage" without pulling in a stemming dependency.
|
||||
for suffix in ("ing", "edly", "ed", "es", "s"):
|
||||
if len(word) > len(suffix) + 2 and word.endswith(suffix):
|
||||
return word[: -len(suffix)]
|
||||
return word
|
||||
|
||||
|
||||
def _tokenize(text: str, language: str) -> set[str]:
|
||||
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
|
||||
words = _WORD_RE.findall(text.lower())
|
||||
return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Document:
|
||||
document_id: str
|
||||
title: str
|
||||
version: str
|
||||
title_tokens: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredSection:
|
||||
document: Document
|
||||
heading: str
|
||||
text: str
|
||||
heading_tokens: set[str]
|
||||
body_tokens: set[str]
|
||||
|
||||
|
||||
def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||
sections: list[tuple[str, str]] = []
|
||||
current_heading = "Overview"
|
||||
current_lines: list[str] = []
|
||||
for line in body.splitlines():
|
||||
if line.startswith("## "):
|
||||
if current_lines:
|
||||
sections.append((current_heading, "\n".join(current_lines).strip()))
|
||||
current_heading = line[3:].strip()
|
||||
current_lines = []
|
||||
elif line.startswith("# "):
|
||||
continue
|
||||
else:
|
||||
current_lines.append(line)
|
||||
if current_lines:
|
||||
sections.append((current_heading, "\n".join(current_lines).strip()))
|
||||
return sections
|
||||
|
||||
|
||||
def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
||||
sections: list[ScoredSection] = []
|
||||
for path in sorted(procedures_dir.glob("*.md")):
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
meta, body = parse_frontmatter(raw)
|
||||
title = meta.get("title", path.stem)
|
||||
doc = Document(
|
||||
document_id=meta.get("document_id", path.stem),
|
||||
title=title,
|
||||
version=meta.get("version", "1.0"),
|
||||
title_tokens=_tokenize(title, language),
|
||||
)
|
||||
for heading, text in _split_sections(body):
|
||||
sections.append(
|
||||
ScoredSection(
|
||||
document=doc,
|
||||
heading=heading,
|
||||
text=text,
|
||||
heading_tokens=_tokenize(heading, language),
|
||||
body_tokens=_tokenize(text, language),
|
||||
)
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
_NO_MATCH_TEXT = {
|
||||
"en-GB": "No matching procedure was found for this question.",
|
||||
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
|
||||
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
|
||||
}
|
||||
_LOW_CONFIDENCE_TEXT = {
|
||||
"en-GB": (
|
||||
"The available procedures do not clearly answer this question. "
|
||||
"The closest matches are included below for review."
|
||||
),
|
||||
"nl-BE": (
|
||||
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
|
||||
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
|
||||
),
|
||||
"fr-BE": (
|
||||
"Les procédures disponibles ne répondent pas clairement à cette question. "
|
||||
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
|
||||
),
|
||||
}
|
||||
_LEAD_ANSWER_TEMPLATE = {
|
||||
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
|
||||
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
|
||||
"fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
|
||||
}
|
||||
|
||||
|
||||
class DemoKnowledgeProvider:
|
||||
"""Deterministic extractive retrieval over the local procedure Markdown files.
|
||||
|
||||
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
|
||||
(downweighting terms common across the whole corpus, like "vehicle", in favor of
|
||||
distinctive ones, like "damage") and returns real excerpts, never invented text.
|
||||
|
||||
Each supported UI language has its own translated procedure corpus under
|
||||
knowledge/procedures/<language>/ -- retrieval searches only within the requested
|
||||
language's corpus so citations always link to a same-language document.
|
||||
"""
|
||||
|
||||
name = "demo"
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self._settings = settings
|
||||
base_dir = Path(settings.knowledge_dir)
|
||||
self._sections_by_language: dict[str, list[ScoredSection]] = {}
|
||||
self._idf_by_language: dict[str, dict[str, float]] = {}
|
||||
self._document_count_by_language: dict[str, int] = {}
|
||||
for language in SUPPORTED_LANGUAGES:
|
||||
lang_dir = base_dir / language
|
||||
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
|
||||
self._sections_by_language[language] = sections
|
||||
self._idf_by_language[language] = self._build_idf(sections)
|
||||
self._document_count_by_language[language] = len(
|
||||
{s.document.document_id for s in sections}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
|
||||
n = len(sections) or 1
|
||||
doc_freq: dict[str, int] = {}
|
||||
for section in sections:
|
||||
doc = section.document
|
||||
all_tokens = doc.title_tokens | section.heading_tokens | section.body_tokens
|
||||
for token in all_tokens:
|
||||
doc_freq[token] = doc_freq.get(token, 0) + 1
|
||||
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
|
||||
|
||||
def _normalize_language(self, language: str | None) -> str:
|
||||
if language in SUPPORTED_LANGUAGES:
|
||||
return language
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
|
||||
language = self._normalize_language(language)
|
||||
return KnowledgeHealth(
|
||||
provider=self.name,
|
||||
available=True,
|
||||
detail="Deterministic keyword-matching demo provider; no external service.",
|
||||
tenant=self._settings.ragcore_tenant,
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
document_count=self._document_count_by_language[language],
|
||||
source_document_count=self._document_count_by_language[language],
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
def _score(
|
||||
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
||||
) -> float:
|
||||
# The section body is the strongest relevance signal -- it's the actual
|
||||
# substance a heading or title can only hint at -- so a body match is weighted
|
||||
# *above* heading/title matches, not below them. The previous 3x/2x/1x
|
||||
# (heading/title/body) ordering let a single generic word in a heading (e.g.
|
||||
# "vehicle", present in nearly every section) or a document's own title
|
||||
# outrank a section whose body genuinely covers multiple, more distinctive
|
||||
# query terms -- confirmed to misrank the brief's exact validation question in
|
||||
# every one of the three languages (see docs/fleet-ops-correction/
|
||||
# current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section
|
||||
# over the damage procedure, en-GB and fr-BE picked the return procedure over
|
||||
# the damage procedure, purely from heading/title overlap on common words.
|
||||
score = 0.0
|
||||
for token in query_tokens:
|
||||
token_idf = idf.get(token, 0.0)
|
||||
if token_idf == 0.0:
|
||||
continue
|
||||
if token in section.body_tokens:
|
||||
score += 3 * token_idf
|
||||
elif token in section.heading_tokens:
|
||||
score += 2 * token_idf
|
||||
elif token in section.document.title_tokens:
|
||||
score += 1.5 * token_idf
|
||||
return score
|
||||
|
||||
def ask(
|
||||
self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
|
||||
) -> GroundedAnswer:
|
||||
language = self._normalize_language(language)
|
||||
sections = self._sections_by_language[language]
|
||||
idf = self._idf_by_language[language]
|
||||
query_tokens = _tokenize(question, language)
|
||||
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
|
||||
scored = [(score, section) for score, section in scored if score > 0]
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
top = scored[:3]
|
||||
|
||||
if not top:
|
||||
return GroundedAnswer(
|
||||
answer=_NO_MATCH_TEXT[language],
|
||||
evidence_state="insufficient",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
sources = [
|
||||
SourceCard(
|
||||
document_id=section.document.document_id,
|
||||
title=section.document.title,
|
||||
version=section.document.version,
|
||||
section=section.heading,
|
||||
excerpt=(section.text[:400] + "…") if len(section.text) > 400 else section.text,
|
||||
)
|
||||
for _, section in top
|
||||
]
|
||||
|
||||
if top[0][0] < 3:
|
||||
return GroundedAnswer(
|
||||
answer=_LOW_CONFIDENCE_TEXT[language],
|
||||
evidence_state="insufficient",
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
lead_section = top[0][1]
|
||||
answer = _LEAD_ANSWER_TEMPLATE[language].format(
|
||||
title=lead_section.document.title,
|
||||
version=lead_section.document.version,
|
||||
heading=lead_section.heading,
|
||||
excerpt=lead_section.text.splitlines()[0][:300],
|
||||
)
|
||||
return GroundedAnswer(
|
||||
answer=answer,
|
||||
evidence_state="grounded",
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||
|
||||
# Stable across runs (and across which language ships first) so a document's RAGcore
|
||||
# source_id never changes just because the sync ran on a different day or in a
|
||||
# different order -- required for RAGcore's upload idempotency to work per document.
|
||||
_SOURCE_ID_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
|
||||
)
|
||||
|
||||
|
||||
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||
if not raw.startswith("---"):
|
||||
return {}, raw
|
||||
end = raw.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}, raw
|
||||
block = raw[3:end].strip()
|
||||
body = raw[end + 4 :].lstrip("\n")
|
||||
meta: dict[str, str] = {}
|
||||
for line in block.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, value = line.partition(":")
|
||||
meta[key.strip()] = value.strip().strip('"')
|
||||
return meta, body
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcedureDocument:
|
||||
source_id: str
|
||||
language: str
|
||||
document_id: str
|
||||
title: str
|
||||
version: str
|
||||
content: str
|
||||
content_hash: str
|
||||
|
||||
|
||||
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
|
||||
"""Read every procedure Markdown file Fleet Ops ships, across every supported
|
||||
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
|
||||
fields (title, version) come from the same files the demo knowledge provider
|
||||
already reads -- see parse_frontmatter -- so the two never drift apart."""
|
||||
|
||||
documents: list[ProcedureDocument] = []
|
||||
for language in SUPPORTED_LANGUAGES:
|
||||
language_dir = knowledge_dir / language
|
||||
if not language_dir.is_dir():
|
||||
continue
|
||||
for path in sorted(language_dir.glob("*.md")):
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
meta, body = parse_frontmatter(raw)
|
||||
document_id = meta.get("document_id", path.stem)
|
||||
content = body.strip()
|
||||
documents.append(
|
||||
ProcedureDocument(
|
||||
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
|
||||
language=language,
|
||||
document_id=document_id,
|
||||
title=meta.get("title", path.stem),
|
||||
version=meta.get("version", "1.0"),
|
||||
content=content,
|
||||
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
)
|
||||
return documents
|
||||
@@ -0,0 +1,577 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE
|
||||
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
|
||||
|
||||
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||
_ANSWERABILITY_STATES = _GROUNDED_ANSWERABILITY | {
|
||||
"not_answerable",
|
||||
"conflicting_evidence",
|
||||
}
|
||||
|
||||
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
|
||||
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
|
||||
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
|
||||
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
|
||||
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
|
||||
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
|
||||
# is deliberately left out of this sentence (it still appears on the source card itself).
|
||||
_LEAD_ANSWER_TEMPLATE = {
|
||||
"en-GB": 'Per "{title}": {excerpt}',
|
||||
"nl-BE": 'Volgens "{title}": {excerpt}',
|
||||
"fr-BE": "Selon « {title} » : {excerpt}",
|
||||
}
|
||||
_DEFAULT_LANGUAGE = "en-GB"
|
||||
_MAX_SOURCE_CARDS = 3
|
||||
_INDEX_LOOKUP_TIMEOUT_SECONDS = 2.0
|
||||
_INDEX_VERIFICATION_TTL_SECONDS = 300.0
|
||||
_INDEX_VERIFICATION_WORKERS = 6
|
||||
|
||||
_DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
|
||||
"damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"),
|
||||
"vehicle": ("vehicle", "car", "voertuig", "wagen", "véhicule", "voiture"),
|
||||
"return": ("return", "returned", "retour", "terugbrengen", "restitution"),
|
||||
"fuel": ("fuel", "brandstof", "carburant"),
|
||||
"booking": ("booking", "reservation", "boeking", "réservation"),
|
||||
"odometer": ("odometer", "mileage", "kilometer", "kilométrage", "compteur"),
|
||||
"customer": ("customer", "client", "klant"),
|
||||
"cleaning": ("cleaning", "reiniging", "poetsen", "nettoyage"),
|
||||
"maintenance": ("maintenance", "service", "onderhoud", "entretien"),
|
||||
"conflict": ("conflict", "overlap", "overlapping", "conflit", "chevauchement"),
|
||||
"checkout": ("checkout", "departure", "vertrek", "départ"),
|
||||
"availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"),
|
||||
"technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"),
|
||||
}
|
||||
_ANSWERABLE_INTENT_CONCEPTS = frozenset(_DOMAIN_CONCEPTS) - {"vehicle", "customer"}
|
||||
|
||||
|
||||
def _normalize_evidence_text(value: str) -> str:
|
||||
return " ".join(re.findall(r"\w+", value.casefold()))
|
||||
|
||||
|
||||
def _question_concepts(question: str) -> set[str]:
|
||||
normalized = question.casefold()
|
||||
return {
|
||||
concept
|
||||
for concept, terms in _DOMAIN_CONCEPTS.items()
|
||||
if any(term in normalized for term in terms)
|
||||
}
|
||||
|
||||
|
||||
def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]:
|
||||
"""Collapse duplicate chunks and re-uploaded document versions.
|
||||
|
||||
RAGcore document/version UUIDs change across uploads, so they are not useful
|
||||
deduplication keys. Human-visible citation identity is the normalized title,
|
||||
section. Chunks from the same unsectioned document collapse into one card; distinct
|
||||
named sections remain independently citable.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique: list[SourceCard] = []
|
||||
for source in sources:
|
||||
key = (
|
||||
source.title.strip().casefold(),
|
||||
source.section.strip().casefold(),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(source)
|
||||
if len(unique) == _MAX_SOURCE_CARDS:
|
||||
break
|
||||
return unique
|
||||
|
||||
|
||||
def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) -> list[SourceCard]:
|
||||
if "damage" not in concepts:
|
||||
return sources
|
||||
return sorted(
|
||||
sources,
|
||||
key=lambda source: (
|
||||
0 if "damage" in f"{source.document_id} {source.title}".casefold() else 1
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _retrieval_score(result: object) -> float | None:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
scores = result.get("scores")
|
||||
if not isinstance(scores, dict):
|
||||
return None
|
||||
for name in ("rerank", "fused"):
|
||||
value = scores.get(name)
|
||||
if isinstance(value, int | float) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
class RAGcoreKnowledgeProvider:
|
||||
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
|
||||
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
|
||||
owned separately, MobilityOps only ever talks to its documented HTTP API).
|
||||
|
||||
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
|
||||
session-cookie auth is for its own browser admin UI only). Any connection error,
|
||||
timeout, non-2xx response, or malformed body degrades to `evidence_state:
|
||||
"unavailable"` rather than raising -- this is the adapter that actually exercises the
|
||||
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
|
||||
never fabricates an answer, never affects the rest of the app.
|
||||
|
||||
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
|
||||
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
|
||||
classifying the question as insufficiently answerable), `ask()` falls back to
|
||||
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
|
||||
stage with no generation step to fail. The fallback answer is always an extractive
|
||||
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
|
||||
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
|
||||
|
||||
Retrieval is scoped to the stable RAGcore ``source_id`` values owned by Fleet Ops for
|
||||
the requested UI language. Returned internal document/version UUIDs are deliberately
|
||||
not treated as Fleet Ops identifiers: every citation must instead prove the complete
|
||||
managed-source chain (source id, URI, locator, checksum and extractive local text).
|
||||
"""
|
||||
|
||||
name = "ragcore"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._settings = get_settings()
|
||||
self._managed_documents_by_source_id: dict[str, ProcedureDocument] = {}
|
||||
for document in iter_procedure_documents(Path(self._settings.knowledge_dir)):
|
||||
self._managed_documents_by_source_id[document.source_id] = document
|
||||
self._verification_cache: dict[str, tuple[float, int]] = {}
|
||||
self._verification_lock = Lock()
|
||||
self._answers_circuit_lock = Lock()
|
||||
self._answers_circuit_open_until = 0.0
|
||||
|
||||
def _managed_source(self, citation: object, language: str) -> SourceCard | None:
|
||||
if not isinstance(citation, dict):
|
||||
return None
|
||||
source_id = citation.get("source_id")
|
||||
if not isinstance(source_id, str):
|
||||
return None
|
||||
document = self._managed_documents_by_source_id.get(str(source_id))
|
||||
if document is None or document.language != language:
|
||||
return None
|
||||
if citation.get("locator") != f"{document.document_id}.md":
|
||||
return None
|
||||
if citation.get("source_uri") != f"ragcore://source/{document.source_id}":
|
||||
return None
|
||||
for field_name in ("id", "document_id", "document_version_id"):
|
||||
value = citation.get(field_name)
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = uuid.UUID(value)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
if parsed.int == 0:
|
||||
return None
|
||||
if not isinstance(citation.get("title"), str):
|
||||
return None
|
||||
section = citation.get("section")
|
||||
if section is not None and not isinstance(section, str):
|
||||
return None
|
||||
excerpt = citation.get("excerpt")
|
||||
if not isinstance(excerpt, str) or not excerpt.strip():
|
||||
return None
|
||||
expected_hash = hashlib.sha256(excerpt.encode("utf-8")).hexdigest()
|
||||
if citation.get("excerpt_sha256") != expected_hash:
|
||||
return None
|
||||
# RAGcore owns chunking, but the cited excerpt must still be extractive evidence
|
||||
# from the authoritative local procedure. Token normalization tolerates Markdown
|
||||
# punctuation/whitespace while rejecting provider text that was never uploaded.
|
||||
normalized_excerpt = _normalize_evidence_text(excerpt)
|
||||
if not normalized_excerpt or normalized_excerpt not in _normalize_evidence_text(
|
||||
document.content
|
||||
):
|
||||
return None
|
||||
return SourceCard(
|
||||
document_id=document.document_id,
|
||||
title=document.title,
|
||||
version=document.version,
|
||||
section=section or "",
|
||||
excerpt=excerpt,
|
||||
)
|
||||
|
||||
def _managed_source_ids(self, language: str) -> list[str]:
|
||||
return [
|
||||
document.source_id
|
||||
for document in self._managed_documents_by_source_id.values()
|
||||
if document.language == language
|
||||
]
|
||||
|
||||
def _answers_circuit_is_open(self) -> bool:
|
||||
with self._answers_circuit_lock:
|
||||
return monotonic() < self._answers_circuit_open_until
|
||||
|
||||
def _open_answers_circuit(self) -> None:
|
||||
with self._answers_circuit_lock:
|
||||
self._answers_circuit_open_until = monotonic() + max(
|
||||
0.0, self._settings.ragcore_answers_circuit_breaker_seconds
|
||||
)
|
||||
|
||||
def _close_answers_circuit(self) -> None:
|
||||
with self._answers_circuit_lock:
|
||||
self._answers_circuit_open_until = 0.0
|
||||
|
||||
def _client(self) -> httpx.Client:
|
||||
headers = {}
|
||||
if self._settings.ragcore_api_token:
|
||||
headers["Authorization"] = f"Bearer {self._settings.ragcore_api_token}"
|
||||
return httpx.Client(
|
||||
base_url=self._settings.ragcore_base_url,
|
||||
headers=headers,
|
||||
timeout=self._settings.ragcore_http_timeout_seconds,
|
||||
)
|
||||
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||
documents = [
|
||||
document
|
||||
for document in iter_procedure_documents(Path(self._settings.knowledge_dir))
|
||||
if document.language == language
|
||||
]
|
||||
verified_document_count: int | None = None
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.get("/health/ready")
|
||||
body = response.json()
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("health response is not a JSON object")
|
||||
available = response.status_code == 200 and body.get("status") == "ok"
|
||||
detail = (
|
||||
"RAGcore reachable and ready."
|
||||
if available
|
||||
else f"RAGcore degraded: {body.get('status', 'unknown')}"
|
||||
)
|
||||
if available:
|
||||
verified_document_count = self._verify_indexed_documents(
|
||||
client, language, documents
|
||||
)
|
||||
if verified_document_count is None:
|
||||
detail += " Index verification is temporarily unavailable."
|
||||
else:
|
||||
detail += (
|
||||
f" {verified_document_count}/{len(documents)} managed sources have "
|
||||
"an exact active published document in the configured space."
|
||||
)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
available = False
|
||||
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||
return KnowledgeHealth(
|
||||
provider=self.name,
|
||||
available=available,
|
||||
detail=detail,
|
||||
tenant=self._settings.ragcore_tenant,
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
# RAGcore deliberately has no browse/count endpoint. Fleet Ops instead
|
||||
# verifies each managed source through its exact identity lookup and only
|
||||
# counts an active document with a published active version. RAGcore's
|
||||
# content_sha256 describes its canonical parsed artifact, not the uploaded
|
||||
# source bytes, so comparing it with Fleet Ops's source hash would be false.
|
||||
document_count=verified_document_count,
|
||||
source_document_count=len(documents),
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state=(
|
||||
"verified" if verified_document_count is not None else "not_reported"
|
||||
),
|
||||
)
|
||||
|
||||
def _verify_indexed_documents(
|
||||
self, client: httpx.Client, language: str, documents: list[ProcedureDocument]
|
||||
) -> int | None:
|
||||
if not self._settings.ragcore_space_id or not documents:
|
||||
return None
|
||||
|
||||
now = monotonic()
|
||||
with self._verification_lock:
|
||||
cached = self._verification_cache.get(language)
|
||||
if cached is not None and now - cached[0] < _INDEX_VERIFICATION_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
def is_verified(document: ProcedureDocument) -> bool:
|
||||
response = client.get(
|
||||
"/v1/documents",
|
||||
params={
|
||||
"source_id": document.source_id,
|
||||
"external_id": f"{document.document_id}.md",
|
||||
},
|
||||
timeout=_INDEX_LOOKUP_TIMEOUT_SECONDS,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError("RAGcore document verification failed")
|
||||
body = response.json()
|
||||
items = body.get("items") if isinstance(body, dict) else None
|
||||
if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
|
||||
return False
|
||||
item = items[0]
|
||||
active_version = item.get("active_version")
|
||||
return bool(
|
||||
item.get("space_id") == self._settings.ragcore_space_id
|
||||
and item.get("source_id") == document.source_id
|
||||
and item.get("external_id") == f"{document.document_id}.md"
|
||||
and item.get("status") == "active"
|
||||
and isinstance(active_version, dict)
|
||||
and active_version.get("status") == "published"
|
||||
)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=min(_INDEX_VERIFICATION_WORKERS, len(documents))
|
||||
) as executor:
|
||||
verified_count = sum(executor.map(is_verified, documents))
|
||||
except (httpx.HTTPError, RuntimeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
with self._verification_lock:
|
||||
self._verification_cache[language] = (monotonic(), verified_count)
|
||||
return verified_count
|
||||
|
||||
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||
unavailable = GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="unavailable",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if not self._settings.ragcore_space_id:
|
||||
return unavailable
|
||||
|
||||
if self._answers_circuit_is_open():
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
|
||||
else:
|
||||
answered = self._ask_via_answers(question, correlation_id, language)
|
||||
if answered is not None:
|
||||
return answered
|
||||
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
|
||||
# real retrieval rather than degrading straight to "unavailable". This never
|
||||
# fabricates an answer to the question: it only ever shows an actually-cited
|
||||
# excerpt RAGcore's own search already found, using the same extractive
|
||||
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
|
||||
# generation step.
|
||||
return self._ask_via_search_fallback(question, correlation_id, language)
|
||||
|
||||
def _ask_via_answers(
|
||||
self, question: str, correlation_id: str, language: str
|
||||
) -> GroundedAnswer | None:
|
||||
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
|
||||
so the caller can fall back to search -- as opposed to a real 200 response
|
||||
classifying the question as insufficiently answerable, which is a genuine,
|
||||
final result, not a reason to fall back."""
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
"/v1/answers",
|
||||
json={
|
||||
"query": question,
|
||||
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||
"filters": {"source_ids": self._managed_source_ids(language)},
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc()
|
||||
self._open_answers_circuit()
|
||||
return None
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc()
|
||||
self._open_answers_circuit()
|
||||
return None
|
||||
|
||||
try:
|
||||
if not isinstance(body, dict):
|
||||
raise TypeError("answer response must be an object")
|
||||
# Validate the provider's complete AnswerResponse contract before trusting
|
||||
# generated prose. These IDs and claim bindings are the evidence that RAGcore
|
||||
# ran its deterministic claim/citation validator; one unrelated but otherwise
|
||||
# valid citation must never make arbitrary answer text appear grounded.
|
||||
uuid.UUID(str(body["answer_id"]))
|
||||
uuid.UUID(str(body["retrieval_run_id"]))
|
||||
raw_citations = body.get("citations", [])
|
||||
if not isinstance(raw_citations, list):
|
||||
raise TypeError("citations must be a list")
|
||||
citation_ids: set[uuid.UUID] = set()
|
||||
for citation in raw_citations:
|
||||
if not isinstance(citation, dict):
|
||||
raise TypeError("citation must be an object")
|
||||
citation_ids.add(uuid.UUID(str(citation["id"])))
|
||||
raw_claims = body["claims"]
|
||||
if not isinstance(raw_claims, list):
|
||||
raise TypeError("claims must be a list")
|
||||
claims_are_bound = bool(raw_claims)
|
||||
answer_text = body.get("answer")
|
||||
for claim in raw_claims:
|
||||
if not isinstance(claim, dict):
|
||||
raise TypeError("claim must be an object")
|
||||
claim_text = claim.get("text")
|
||||
claim_citation_ids = claim.get("citation_ids")
|
||||
if (
|
||||
not isinstance(claim_text, str)
|
||||
or not claim_text.strip()
|
||||
or not isinstance(answer_text, str)
|
||||
or claim_text.strip() not in answer_text
|
||||
or not isinstance(claim_citation_ids, list)
|
||||
or not claim_citation_ids
|
||||
):
|
||||
claims_are_bound = False
|
||||
continue
|
||||
try:
|
||||
bound_ids = {uuid.UUID(str(item)) for item in claim_citation_ids}
|
||||
except (TypeError, ValueError):
|
||||
claims_are_bound = False
|
||||
continue
|
||||
if not bound_ids.issubset(citation_ids):
|
||||
claims_are_bound = False
|
||||
mapped_sources = [
|
||||
source
|
||||
for citation in raw_citations
|
||||
if (source := self._managed_source(citation, language)) is not None
|
||||
]
|
||||
citations_are_managed = len(mapped_sources) == len(raw_citations)
|
||||
sources = _deduplicate_sources(mapped_sources)
|
||||
answerability = body.get("answerability", "not_answerable")
|
||||
if answerability not in _ANSWERABILITY_STATES:
|
||||
raise TypeError("answerability is invalid")
|
||||
if not isinstance(answer_text, str):
|
||||
raise TypeError("answer must be a string")
|
||||
is_grounded = (
|
||||
answerability in _GROUNDED_ANSWERABILITY
|
||||
and bool(sources)
|
||||
and citations_are_managed
|
||||
and claims_are_bound
|
||||
and bool(answer_text.strip())
|
||||
)
|
||||
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
|
||||
self._close_answers_circuit()
|
||||
return GroundedAnswer(
|
||||
answer=answer_text if evidence_state == "grounded" else "",
|
||||
evidence_state=evidence_state,
|
||||
sources=sources if evidence_state == "grounded" else [],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
except (TypeError, KeyError, ValueError):
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc()
|
||||
self._open_answers_circuit()
|
||||
return None
|
||||
|
||||
def _ask_via_search_fallback(
|
||||
self, question: str, correlation_id: str, language: str
|
||||
) -> GroundedAnswer:
|
||||
unavailable = GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="unavailable",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": question,
|
||||
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||
"filters": {"source_ids": self._managed_source_ids(language)},
|
||||
"max_results": 5,
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc()
|
||||
return unavailable
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc()
|
||||
return unavailable
|
||||
|
||||
try:
|
||||
if not isinstance(body, dict):
|
||||
raise TypeError("search response must be an object")
|
||||
results = body.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
raise TypeError("results must be a list")
|
||||
sources = [
|
||||
source
|
||||
for result in results
|
||||
if isinstance(result, dict)
|
||||
and (source := self._managed_source(result.get("citation"), language)) is not None
|
||||
]
|
||||
except (TypeError, KeyError, ValueError):
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
|
||||
return unavailable
|
||||
|
||||
for result in results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
score = _retrieval_score(result)
|
||||
if score is not None:
|
||||
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
|
||||
|
||||
all_sources = _deduplicate_sources(sources)
|
||||
concepts = _question_concepts(question)
|
||||
qualified_sources = [
|
||||
source
|
||||
for result in results
|
||||
if isinstance(result, dict)
|
||||
and (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
|
||||
and (source := self._managed_source(result.get("citation"), language)) is not None
|
||||
]
|
||||
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
|
||||
if not all_sources:
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="insufficient",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS
|
||||
evidence_text = " ".join(
|
||||
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
|
||||
for source in sources
|
||||
).casefold()
|
||||
covered_concepts = {
|
||||
concept
|
||||
for concept in required_concepts
|
||||
if any(term in evidence_text for term in _DOMAIN_CONCEPTS[concept])
|
||||
}
|
||||
if not required_concepts or not sources or covered_concepts != required_concepts:
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="insufficient",
|
||||
sources=all_sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
|
||||
lead = sources[0]
|
||||
answer = template.format(title=lead.title, excerpt=lead.excerpt)
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc()
|
||||
return GroundedAnswer(
|
||||
answer=answer,
|
||||
evidence_state="grounded",
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import DashboardMetrics
|
||||
|
||||
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def compute_metrics(db: Session) -> DashboardMetrics:
|
||||
"""Shared operations-summary computation used by both the dashboard and the MCP
|
||||
provider API, so the two never drift out of sync with two copies of the same query."""
|
||||
status_counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
open_issues = db.scalar(
|
||||
select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open")
|
||||
)
|
||||
pending_or_failed = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(OutboxEvent)
|
||||
.where(OutboxEvent.delivery_status.in_(["pending", "failed"]))
|
||||
)
|
||||
return DashboardMetrics(
|
||||
available=status_counts.get("available", 0),
|
||||
rented=status_counts.get("rented", 0),
|
||||
cleaning=status_counts.get("cleaning", 0),
|
||||
maintenance=status_counts.get("maintenance", 0),
|
||||
blocked=status_counts.get("blocked", 0),
|
||||
open_quality_issues=open_issues or 0,
|
||||
pending_or_failed_workflows=pending_or_failed or 0,
|
||||
)
|
||||
|
||||
|
||||
def list_attention_vehicles(
|
||||
db: Session, minimum_severity: str = "medium", on_or_before: date | None = None, limit: int = 20
|
||||
) -> list[dict]:
|
||||
max_rank = SEVERITY_ORDER.get(minimum_severity, 1)
|
||||
stmt = (
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.status == "open")
|
||||
.order_by(DataQualityIssue.detected_at.asc())
|
||||
)
|
||||
if on_or_before is not None:
|
||||
stmt = stmt.where(func.date(DataQualityIssue.detected_at) <= on_or_before)
|
||||
issues = db.scalars(stmt).all()
|
||||
filtered = [i for i in issues if SEVERITY_ORDER.get(i.severity, 3) <= max_rank]
|
||||
filtered.sort(key=lambda i: SEVERITY_ORDER.get(i.severity, 3))
|
||||
|
||||
vehicle_ids = {i.entity_id for i in filtered}
|
||||
vehicles_by_id = {
|
||||
v.id: v for v in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
|
||||
}
|
||||
|
||||
results = []
|
||||
for issue in filtered[:limit]:
|
||||
vehicle = vehicles_by_id.get(issue.entity_id)
|
||||
if vehicle is None:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"vehicle_ref": vehicle.public_ref,
|
||||
"severity": issue.severity,
|
||||
"rule_type": issue.rule_type,
|
||||
"summary": issue.evidence_json.get("summary", ""),
|
||||
"detected_at": issue.detected_at,
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, RegisterReturnRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import open_odometer_regression_issue
|
||||
|
||||
|
||||
def _new_inspection_ref() -> str:
|
||||
"""Generate a collision-resistant public reference without reading mutable counts.
|
||||
|
||||
Return commands for different bookings can commit concurrently. A count-based
|
||||
reference made those independent transactions race for the same unique value.
|
||||
"""
|
||||
return f"INSP-{uuid.uuid4().hex[:10].upper()}"
|
||||
|
||||
|
||||
def _derive_vehicle_status_with_reason(
|
||||
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
||||
) -> tuple[str, str, dict[str, str | int]]:
|
||||
# Stable, localizable codes + params -- the backend never emits prose here. The
|
||||
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
|
||||
# raw-English fallback strings live only in status_reason (shown under "Technical
|
||||
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
|
||||
if body.damage_reported and body.technical_warning:
|
||||
return (
|
||||
"blocked",
|
||||
"returnBlockedDamageAndTechnical",
|
||||
{},
|
||||
)
|
||||
if body.damage_reported:
|
||||
return "blocked", "returnBlockedDamage", {}
|
||||
if body.technical_warning:
|
||||
return "blocked", "returnBlockedTechnicalWarning", {}
|
||||
if new_odometer >= vehicle.next_service_km:
|
||||
return (
|
||||
"maintenance",
|
||||
"returnServiceThresholdReached",
|
||||
{"threshold_km": vehicle.next_service_km},
|
||||
)
|
||||
return "cleaning", "returnRoutedToCleaning", {}
|
||||
|
||||
|
||||
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
|
||||
"returnBlockedDamageAndTechnical": (
|
||||
"Damage and a technical warning were both reported on return."
|
||||
),
|
||||
"returnBlockedDamage": "Damage was reported on return.",
|
||||
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
|
||||
"returnServiceThresholdReached": "Odometer reached the service threshold.",
|
||||
"returnRoutedToCleaning": (
|
||||
"No damage, technical warning or service threshold; routed to cleaning."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReturnEvaluation:
|
||||
canonical_odometer_km: int
|
||||
submitted_odometer_km: int
|
||||
odometer_regression: bool
|
||||
resulting_odometer_km: int
|
||||
resulting_vehicle_status: str
|
||||
status_reason: str
|
||||
status_reason_code: str
|
||||
status_reason_params: dict[str, str | int]
|
||||
would_create_quality_issue: bool
|
||||
attention_reasons: list[str]
|
||||
next_booking_risk: dict | None
|
||||
|
||||
|
||||
def evaluate_return(
|
||||
db: Session, booking: Booking, vehicle: Vehicle, body: RegisterReturnRequest, *, now: datetime
|
||||
) -> ReturnEvaluation:
|
||||
"""Pure evaluation of what a return would do. No writes; safe to call from a
|
||||
non-mutating preview endpoint. `register_vehicle_return` uses the same function so
|
||||
preview and commit can never drift apart."""
|
||||
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
||||
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
|
||||
body, vehicle, resulting_odometer_km
|
||||
)
|
||||
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
|
||||
|
||||
attention_reasons = []
|
||||
if body.damage_reported:
|
||||
attention_reasons.append("damage_reported")
|
||||
if body.technical_warning:
|
||||
attention_reasons.append("technical_warning")
|
||||
if odometer_regression:
|
||||
attention_reasons.append("odometer_regression")
|
||||
|
||||
next_booking = db.scalar(
|
||||
select(Booking)
|
||||
.where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status == "reserved",
|
||||
Booking.starts_at > now,
|
||||
)
|
||||
.order_by(Booking.starts_at.asc())
|
||||
)
|
||||
next_booking_risk = None
|
||||
if next_booking is not None:
|
||||
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
|
||||
next_booking_risk = {
|
||||
"booking_ref": next_booking.public_ref,
|
||||
"starts_at": next_booking.starts_at.isoformat(),
|
||||
"at_risk": resulting_status != "cleaning" or hours_until < 4,
|
||||
}
|
||||
|
||||
return ReturnEvaluation(
|
||||
canonical_odometer_km=vehicle.odometer_km,
|
||||
submitted_odometer_km=body.end_odometer_km,
|
||||
odometer_regression=odometer_regression,
|
||||
resulting_odometer_km=resulting_odometer_km,
|
||||
resulting_vehicle_status=resulting_status,
|
||||
status_reason=status_reason,
|
||||
status_reason_code=status_reason_code,
|
||||
status_reason_params=status_reason_params,
|
||||
would_create_quality_issue=odometer_regression,
|
||||
attention_reasons=attention_reasons,
|
||||
next_booking_risk=next_booking_risk,
|
||||
)
|
||||
|
||||
|
||||
def _load_active_booking_and_vehicle(
|
||||
db: Session, booking_ref: str, *, lock: bool
|
||||
) -> tuple[Booking, Vehicle]:
|
||||
stmt = select(Booking).where(Booking.public_ref == booking_ref)
|
||||
if lock:
|
||||
stmt = stmt.with_for_update()
|
||||
booking = db.scalar(stmt)
|
||||
if booking is None:
|
||||
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
|
||||
|
||||
vehicle_stmt = select(Vehicle).where(Vehicle.id == booking.vehicle_id)
|
||||
if lock:
|
||||
vehicle_stmt = vehicle_stmt.with_for_update()
|
||||
vehicle = db.scalar(vehicle_stmt)
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
|
||||
)
|
||||
return booking, vehicle
|
||||
|
||||
|
||||
def preview_vehicle_return(
|
||||
db: Session, booking_ref: str, body: RegisterReturnRequest
|
||||
) -> tuple[Booking, Vehicle, ReturnEvaluation]:
|
||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=False)
|
||||
if booking.status != "active":
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_STATE",
|
||||
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
|
||||
status_code=409,
|
||||
)
|
||||
evaluation = evaluate_return(db, booking, vehicle, body, now=datetime.now(UTC))
|
||||
return booking, vehicle, evaluation
|
||||
|
||||
|
||||
def request_fingerprint(body: RegisterReturnRequest) -> str:
|
||||
canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _replay_or_reject(
|
||||
db: Session,
|
||||
existing: IdempotencyRecord,
|
||||
booking_ref: str,
|
||||
fingerprint: str,
|
||||
) -> tuple[int, dict]:
|
||||
booking = db.get(Booking, existing.booking_id)
|
||||
if booking is None or booking.public_ref != booking_ref:
|
||||
raise AppError(
|
||||
"IDEMPOTENCY_KEY_REUSED",
|
||||
"This idempotency key was already used for a different booking.",
|
||||
status_code=409,
|
||||
)
|
||||
if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint:
|
||||
raise AppError(
|
||||
"IDEMPOTENCY_KEY_REUSED",
|
||||
"This idempotency key was already used with a different request body.",
|
||||
status_code=409,
|
||||
)
|
||||
return existing.response_status, existing.response_body
|
||||
|
||||
|
||||
def register_vehicle_return(
|
||||
db: Session,
|
||||
booking_ref: str,
|
||||
body: RegisterReturnRequest,
|
||||
idempotency_key: str,
|
||||
actor: CurrentUser,
|
||||
) -> tuple[int, dict]:
|
||||
fingerprint = request_fingerprint(body)
|
||||
existing = db.scalar(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
|
||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||
|
||||
# Re-check after acquiring the row lock: a concurrent identical-key request may have
|
||||
# just committed while we were waiting.
|
||||
existing = db.scalar(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
|
||||
if booking.status != "active":
|
||||
raise AppError(
|
||||
"INVALID_BOOKING_STATE",
|
||||
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
correlation_id = uuid.uuid4()
|
||||
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
|
||||
|
||||
inspection = Inspection(
|
||||
public_ref=_new_inspection_ref(),
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
fuel_level_percent=body.fuel_level_percent,
|
||||
cleanliness_ok=body.cleanliness_ok,
|
||||
damage_reported=body.damage_reported,
|
||||
technical_warning=body.technical_warning,
|
||||
notes=body.notes,
|
||||
odometer_km=body.end_odometer_km,
|
||||
completed_at=now,
|
||||
completed_by=actor.display_name,
|
||||
)
|
||||
db.add(inspection)
|
||||
|
||||
before_vehicle = {
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
|
||||
booking.status = "returned"
|
||||
booking.end_odometer_km = body.end_odometer_km
|
||||
|
||||
quality_issue_ref: str | None = None
|
||||
if evaluation.odometer_regression:
|
||||
issue = open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=inspection.public_ref,
|
||||
reading_km=body.end_odometer_km,
|
||||
canonical_km=evaluation.canonical_odometer_km,
|
||||
source_type="return",
|
||||
related_refs=[booking.public_ref, inspection.public_ref],
|
||||
correctable_booking_refs=[booking.public_ref],
|
||||
detected_at=now,
|
||||
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
||||
actor_label=actor.display_name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
quality_issue_ref = issue.public_ref if issue is not None else None
|
||||
|
||||
resulting_status = evaluation.resulting_vehicle_status
|
||||
vehicle.odometer_km = evaluation.resulting_odometer_km
|
||||
vehicle.operational_status = resulting_status
|
||||
vehicle.version += 1
|
||||
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="return_registered",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
correlation_id=correlation_id,
|
||||
before={"status": "active"},
|
||||
after={"status": "returned", "end_odometer_km": body.end_odometer_km},
|
||||
metadata={"idempotency_key": idempotency_key},
|
||||
)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="vehicle_status_changed",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before_vehicle,
|
||||
after={
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
},
|
||||
)
|
||||
|
||||
event = OutboxEvent(
|
||||
event_id=uuid.uuid4(),
|
||||
event_type="vehicle.returned.v1",
|
||||
aggregate_type="booking",
|
||||
aggregate_id=booking.id,
|
||||
payload_json={
|
||||
"event_type": "vehicle.returned.v1",
|
||||
"correlation_id": str(correlation_id),
|
||||
"aggregate": {
|
||||
"type": "booking",
|
||||
"id": str(booking.id),
|
||||
"public_ref": booking.public_ref,
|
||||
},
|
||||
"data": {
|
||||
"vehicle_ref": vehicle.public_ref,
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"resulting_vehicle_status": resulting_status,
|
||||
"attention_reasons": evaluation.attention_reasons,
|
||||
},
|
||||
"aggregate_ref": booking.public_ref,
|
||||
},
|
||||
occurred_at=now,
|
||||
delivery_status="pending",
|
||||
attempts=0,
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
response_body = {
|
||||
"booking_ref": booking.public_ref,
|
||||
"vehicle_ref": vehicle.public_ref,
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"resulting_vehicle_status": resulting_status,
|
||||
"odometer_regression": evaluation.odometer_regression,
|
||||
"quality_issue_ref": quality_issue_ref,
|
||||
"workflow_event_id": str(event.event_id),
|
||||
"correlation_id": str(correlation_id),
|
||||
"next_booking_risk": evaluation.next_booking_risk,
|
||||
}
|
||||
|
||||
db.add(
|
||||
IdempotencyRecord(
|
||||
idempotency_key=idempotency_key,
|
||||
booking_id=booking.id,
|
||||
request_fingerprint=fingerprint,
|
||||
response_status=201,
|
||||
response_body=response_body,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
existing = db.scalar(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
raise
|
||||
|
||||
return 201, response_body
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import SessionPayload, session_token_hash
|
||||
from app.models.revoked_session import RevokedSession
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def is_session_revoked(db: Session, token: str) -> bool:
|
||||
token_hash = session_token_hash(token)
|
||||
revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||
return revoked_id is not None
|
||||
|
||||
|
||||
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
|
||||
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
|
||||
token_hash = session_token_hash(token)
|
||||
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||
if existing is not None:
|
||||
return
|
||||
db.add(
|
||||
RevokedSession(
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.fromtimestamp(
|
||||
payload.issued_at + settings.session_ttl_seconds, UTC
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""The single authoritative vehicle-status evaluator.
|
||||
|
||||
Used by the data-quality scanner (detection), the status-recommendation preview
|
||||
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
|
||||
recommendation can never structurally disagree (see docs/fleet-ops-correction/
|
||||
vehicle-status-decision-table.md for the full decision table and rationale).
|
||||
|
||||
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
|
||||
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
|
||||
never from a proxy like "does some other high-severity issue happen to be open". It is
|
||||
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
|
||||
the same vehicle never changes what this function returns, because it never looks at
|
||||
issue history, only at the vehicle's/bookings' current state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
# Every code below is a stable, localizable identifier -- see
|
||||
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
|
||||
# human-language mapping in all three supported locales. The backend never emits prose.
|
||||
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
|
||||
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
|
||||
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
|
||||
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
|
||||
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
|
||||
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
|
||||
|
||||
|
||||
@dataclass
|
||||
class VehicleStatusFacts:
|
||||
active_booking_refs: list[str] = field(default_factory=list)
|
||||
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
|
||||
service_threshold_reached: bool = False
|
||||
odometer_km: int = 0
|
||||
next_service_km: int = 0
|
||||
open_booking_overlap_issue_ref: str | None = None
|
||||
|
||||
@property
|
||||
def has_active_rental(self) -> bool:
|
||||
return len(self.active_booking_refs) > 0
|
||||
|
||||
@property
|
||||
def has_booking_conflict(self) -> bool:
|
||||
return (
|
||||
len(self.overlapping_booking_pairs) > 0
|
||||
or self.open_booking_overlap_issue_ref is not None
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"active_booking_refs": self.active_booking_refs,
|
||||
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
|
||||
"service_threshold_reached": self.service_threshold_reached,
|
||||
"odometer_km": self.odometer_km,
|
||||
"next_service_km": self.next_service_km,
|
||||
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VehicleStatusRecommendation:
|
||||
current_status: str
|
||||
recommended_status: str | None
|
||||
recommendation_code: str
|
||||
safe_to_apply: bool
|
||||
manual_review_required: bool
|
||||
facts: VehicleStatusFacts
|
||||
blocking_reasons: list[str]
|
||||
|
||||
|
||||
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
|
||||
ordered = sorted(bookings, key=lambda b: b.starts_at)
|
||||
pairs: list[tuple[Booking, Booking]] = []
|
||||
for i, first in enumerate(ordered):
|
||||
for second in ordered[i + 1 :]:
|
||||
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||
pairs.append((first, second))
|
||||
return pairs
|
||||
|
||||
|
||||
def gather_vehicle_status_facts(
|
||||
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
|
||||
) -> VehicleStatusFacts:
|
||||
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
|
||||
derived from another issue's mere existence (only a *specific* booking_overlap
|
||||
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
|
||||
reserved_or_active = list(
|
||||
db.scalars(
|
||||
select(Booking).where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status.in_(["reserved", "active"]),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
|
||||
overlap_pairs = [
|
||||
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
|
||||
]
|
||||
|
||||
overlap_issue_query = select(DataQualityIssue.public_ref).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.rule_type == "booking_overlap",
|
||||
)
|
||||
if exclude_issue_id is not None:
|
||||
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
|
||||
open_overlap_ref = db.scalar(overlap_issue_query)
|
||||
|
||||
return VehicleStatusFacts(
|
||||
active_booking_refs=active_refs,
|
||||
overlapping_booking_pairs=overlap_pairs,
|
||||
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
|
||||
odometer_km=vehicle.odometer_km,
|
||||
next_service_km=vehicle.next_service_km,
|
||||
open_booking_overlap_issue_ref=open_overlap_ref,
|
||||
)
|
||||
|
||||
|
||||
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
|
||||
"""A short digest of exactly the facts the recommendation was based on, plus the
|
||||
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
|
||||
facts and rejects the request if it doesn't match the token the client last saw --
|
||||
the frontend must never assume a previously-shown preview is still valid without the
|
||||
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
|
||||
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
|
||||
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
|
||||
return digest[:16]
|
||||
|
||||
|
||||
def evaluate_vehicle_status(
|
||||
vehicle: Vehicle, facts: VehicleStatusFacts
|
||||
) -> VehicleStatusRecommendation:
|
||||
"""Pure decision logic over already-gathered facts -- see
|
||||
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
|
||||
never queries the database itself (call gather_vehicle_status_facts first), so it is
|
||||
trivial to unit-test every branch in isolation."""
|
||||
current = vehicle.operational_status
|
||||
blocking_reasons: list[str] = []
|
||||
if facts.service_threshold_reached:
|
||||
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||
if facts.has_booking_conflict:
|
||||
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
|
||||
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
|
||||
# Already being in maintenance is itself a real blocking fact -- an active
|
||||
# booking never overrides it. This is exactly the forbidden shortcut this
|
||||
# evaluator must never take (maintenance + active booking -> auto "rented").
|
||||
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||
|
||||
def result(
|
||||
recommended: str | None, code: str, *, safe: bool, manual: bool
|
||||
) -> VehicleStatusRecommendation:
|
||||
return VehicleStatusRecommendation(
|
||||
current_status=current,
|
||||
recommended_status=recommended,
|
||||
recommendation_code=code,
|
||||
safe_to_apply=safe,
|
||||
manual_review_required=manual,
|
||||
facts=facts,
|
||||
blocking_reasons=blocking_reasons,
|
||||
)
|
||||
|
||||
if facts.has_active_rental and not blocking_reasons:
|
||||
if current == "rented":
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
return result("rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False)
|
||||
|
||||
if facts.has_active_rental and blocking_reasons:
|
||||
# Explicitly forbidden shortcut this evaluator must never take: an active
|
||||
# booking is not proof the vehicle should be "rented" when a real blocking
|
||||
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
|
||||
# This is a real contradiction in the underlying facts, not something safe to
|
||||
# resolve automatically.
|
||||
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||
|
||||
if facts.service_threshold_reached:
|
||||
if current == "maintenance":
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
return result("maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False)
|
||||
|
||||
if facts.has_booking_conflict:
|
||||
if current == "blocked":
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
return result("blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False)
|
||||
|
||||
# No active rental, no maintenance need, no booking conflict.
|
||||
if current in ("available", "cleaning", "blocked"):
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
if current == "rented":
|
||||
return result("available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False)
|
||||
if current == "maintenance":
|
||||
# No positive fact confirms maintenance is actually finished (no completed
|
||||
# service record is tracked here) -- clearing "maintenance" without such a
|
||||
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
|
||||
# Releasing a vehicle from maintenance remains an explicit, manual decision.
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
|
||||
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||
Reference in new issue
Block a user