M54: harden operations and demo resilience
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-24 03:31:03 +02:00
parent b0706989db
commit 81e3fd63bd
101 changed files with 5641 additions and 828 deletions
+122 -202
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
@@ -11,10 +11,22 @@ 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, ResolveOdometerRegressionRequest
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,
@@ -28,15 +40,6 @@ REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
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))
@dataclass
class ScanResult:
created: dict[str, int] = field(default_factory=dict)
@@ -45,25 +48,6 @@ class ScanResult:
self.created[rule_type] = self.created.get(rule_type, 0) + 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 _open_issue(
db: Session,
scan: ScanResult,
@@ -76,6 +60,7 @@ def _open_issue(
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
@@ -103,6 +88,7 @@ def _open_issue(
"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
@@ -235,57 +221,110 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
)
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
# The seed dataset's vehicle.odometer_km is generated independently of booking
# history, so comparing every historical booking against it produces near-universal
# false positives. Instead check the booking sequence's own internal consistency:
# each vehicle's completed bookings should show a non-decreasing odometer reading.
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars(
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():
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
).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, bookings in bookings_by_vehicle.items():
bookings.sort(key=lambda b: b.ends_at)
for earlier, later in zip(bookings, bookings[1:], strict=False):
# The query above filters end_odometer_km IS NOT NULL, so both are ints here.
assert earlier.end_odometer_km is not None
assert later.end_odometer_km is not None
if later.end_odometer_km < earlier.end_odometer_km:
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]
_open_issue(
had_open_issue = _has_open_issue(
db,
scan,
rule_type="odometer_regression",
entity_type="vehicle",
entity_id=vehicle_id,
severity="medium",
summary=(
f"Booking {later.public_ref} recorded {later.end_odometer_km} km, "
f"below the {earlier.end_odometer_km} km recorded by earlier "
f"booking {earlier.public_ref}."
),
entity_ref=vehicle.public_ref,
related_refs=[earlier.public_ref, later.public_ref],
signals=[
{
"code": "odometer.regression",
"params": {
"later_ref": later.public_ref,
"later_km": later.end_odometer_km,
"earlier_ref": earlier.public_ref,
"earlier_km": earlier.end_odometer_km,
},
}
],
"odometer_regression",
"vehicle",
vehicle_id,
)
break
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"
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.
@@ -293,7 +332,12 @@ def run_scan(
scan = ScanResult()
scan_duplicate_customers(db, scan, _open_issue)
_scan_missing_required_fields(db, scan)
_scan_odometer_regressions(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:
@@ -305,28 +349,13 @@ def run_scan(
entity_type="system",
metadata={"created": scan.created},
)
db.commit()
if commit:
db.commit()
else:
db.flush()
return scan
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()
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
def defer_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
issue.status = "deferred"
@@ -456,115 +485,6 @@ def provide_missing_fields(
return issue
def resolve_odometer_regression(
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "odometer_regression":
raise AppError(
"NOT_AN_ODOMETER_ISSUE",
"This issue is not an odometer_regression issue.",
status_code=409,
)
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
booking: Booking | None = None
if body.decision != "retain_canonical":
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
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.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
)
correlation_id = uuid.uuid4()
if body.decision == "retain_canonical":
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},
)
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,
)
before = {
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
}
booking.end_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,
},
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
)
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
def resolve_booking_overlap(
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
) -> DataQualityIssue:
@@ -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
@@ -90,9 +90,7 @@ def scan_duplicate_customers[ScanType: ScanAccumulator](
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)}}
)
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
@@ -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
+23 -10
View File
@@ -9,16 +9,28 @@ 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
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 get_knowledge_provider
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)
@@ -49,7 +61,8 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
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 failed_run.delivery_status == "failed")
automation_ready = bool(failed_run and is_demo_scenario_failure(failed_run))
knowledge_ready = _knowledge_scenario_ready(knowledge_health)
return [
DemoScenarioOut(
@@ -119,8 +132,8 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
estimated_minutes=2,
required_roles=["rental_employee", "operations_manager"],
start_path="/knowledge",
ready=knowledge_health.available,
blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
ready=knowledge_ready,
blocked_reason_code=None if knowledge_ready else "knowledgeUnavailable",
),
]
@@ -145,9 +158,11 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
key="ragcore",
status_code=(
"operational"
if knowledge_health.provider == "ragcore" and knowledge_health.available
if knowledge_health.provider == "ragcore"
and _knowledge_scenario_ready(knowledge_health)
else "demoMode"
if knowledge_health.provider == "demo" and knowledge_health.available
if knowledge_health.provider == "demo"
and _knowledge_scenario_ready(knowledge_health)
else "unavailable"
),
detail_code=(
@@ -169,9 +184,7 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
# "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"
"mcpDetailOperational" if mcp_hub.state == "operational" else "mcpDetailPrepared"
),
detail_params={},
),
+59 -5
View File
@@ -77,6 +77,14 @@ def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
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:
@@ -109,6 +117,9 @@ def _deliver_one(event_id: uuid.UUID) -> None:
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()
@@ -130,9 +141,33 @@ def _deliver_one(event_id: uuid.UUID) -> None:
except ValueError:
body = None
if isinstance(body, dict):
success = bool(body.get("ok", True))
error = None if success else f"n8n reported failure: {body}"
error_code = None if success else "remoteReportedFailure"
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
@@ -152,16 +187,35 @@ def _deliver_one(event_id: uuid.UUID) -> None:
db = SessionLocal()
try:
event = db.get(OutboxEvent, event_id)
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
event.external_run_id = str((body or {}).get("event_id", event_id))
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"
+45 -8
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any, Literal
import httpx
from sqlalchemy import Row, func, select
from sqlalchemy import Row, func, or_, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -93,7 +93,10 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
or_(
OutboxEvent.last_error_code.is_(None),
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
),
)
)
latest_demo_scenario_at = db.scalar(
@@ -106,11 +109,17 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
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"
elif succeeded > 0 or pending > 0 or delivering > 0:
# 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"
@@ -128,8 +137,14 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
# 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_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
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
@@ -198,6 +213,24 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
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"
@@ -265,16 +298,20 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
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:
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"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state=state,
+167 -45
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import hashlib
import re
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Lock
@@ -13,6 +16,10 @@ from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealt
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
@@ -47,6 +54,11 @@ _DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
"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]:
@@ -93,7 +105,9 @@ def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) ->
)
def _retrieval_score(result: dict) -> float | None:
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
@@ -124,23 +138,81 @@ class RAGcoreKnowledgeProvider:
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
chunk's `language` payload field as `"en"` regardless of actual document language (the
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
Filtering search/answer requests by requested UI language would therefore silently
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
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
@@ -284,7 +356,7 @@ class RAGcoreKnowledgeProvider:
if self._answers_circuit_is_open():
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
else:
answered = self._ask_via_answers(question, correlation_id)
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
@@ -295,7 +367,9 @@ class RAGcoreKnowledgeProvider:
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
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,
@@ -307,6 +381,7 @@ class RAGcoreKnowledgeProvider:
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"filters": {"source_ids": self._managed_source_ids(language)},
},
)
if response.status_code != 200:
@@ -320,25 +395,73 @@ class RAGcoreKnowledgeProvider:
return None
try:
citations = {c["id"]: c for c in body.get("citations", [])}
sources = [
SourceCard(
document_id=str(citation["document_id"]),
title=citation["title"],
version=str(citation["document_version_id"]),
section=citation.get("section") or "",
excerpt=citation["excerpt"],
)
for citation in citations.values()
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
]
sources = _deduplicate_sources(sources)
citations_are_managed = len(mapped_sources) == len(raw_citations)
sources = _deduplicate_sources(mapped_sources)
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
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=body.get("answer", "") if evidence_state == "grounded" else "",
answer=answer_text if evidence_state == "grounded" else "",
evidence_state=evidence_state,
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
@@ -366,6 +489,7 @@ class RAGcoreKnowledgeProvider:
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"filters": {"source_ids": self._managed_source_ids(language)},
"max_results": 5,
},
)
@@ -378,24 +502,24 @@ class RAGcoreKnowledgeProvider:
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 = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
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)
@@ -403,15 +527,11 @@ class RAGcoreKnowledgeProvider:
all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question)
qualified_sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
source
for result in results
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
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:
@@ -424,15 +544,17 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id,
)
damage_evidence = any(
term
in (
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
).casefold()
required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS
evidence_text = " ".join(
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
for source in sources
for term in _DOMAIN_CONCEPTS["damage"]
)
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
).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="",
+15 -22
View File
@@ -4,7 +4,7 @@ import hashlib
import json
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -12,13 +12,13 @@ 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.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:
@@ -261,28 +261,21 @@ def register_vehicle_return(
quality_issue_ref: str | None = None
if evaluation.odometer_regression:
issue = DataQualityIssue(
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
rule_type="odometer_regression",
entity_type="vehicle",
entity_id=vehicle.id,
severity="medium",
status="open",
evidence_json={
"summary": (
f"Return submitted {body.end_odometer_km} km, below canonical "
f"{evaluation.canonical_odometer_km} km."
),
"entity_ref": vehicle.public_ref,
"related_refs": [booking.public_ref, inspection.public_ref],
},
proposed_action_json={},
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,
due_at=now + timedelta(days=1),
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
actor_label=actor.display_name,
correlation_id=correlation_id,
)
db.add(issue)
db.flush()
quality_issue_ref = issue.public_ref
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