aldsouza's picture
Summarization fix
a31ebca
Raw
History Blame Contribute Delete
10.2 kB
import logging
import obd
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from diagnosis_tools import DIAGNOSIS_TOOLS
from obd_connection import (
get_poll_pids,
obd_session_lock,
probe_obd_vitals,
read_dtcs,
read_live_vitals,
)
from obd_vitals_catalog import format_obd_vitals_reference
from vitals_store import get_range, get_recent, get_vital_history, get_vital_history_in_range
logger = logging.getLogger("car-diagnostic-agent.tools")
# Labels persisted by the background poller (see POLL_PIDS in obd_connection.py).
STORED_VITAL_LABELS: tuple[str, ...] = tuple(
label for label, _ in get_poll_pids()
)
_KEY_LABELS = STORED_VITAL_LABELS
RANGE_SNAPSHOT_CAP = 60
def _compact_snapshot(snapshot: dict) -> dict:
readings = snapshot.get("readings") or {}
if isinstance(readings, list):
readings = {r["label"]: r["value"] for r in readings if "label" in r}
compact_readings = {k: readings[k] for k in _KEY_LABELS if k in readings}
return {
"ts": snapshot["ts"],
"dtcs": snapshot.get("dtcs") or [],
"readings": compact_readings,
}
def _agent_snapshot(snapshot: dict) -> dict:
"""Return compact snapshot fields visible to the diagnostic agent."""
compact = _compact_snapshot(snapshot)
compact["id"] = snapshot["id"]
return compact
def _cap_snapshots(snapshots: list[dict]) -> tuple[list[dict], bool]:
if len(snapshots) <= RANGE_SNAPSHOT_CAP:
return snapshots, False
return snapshots[:RANGE_SNAPSHOT_CAP], True
def _compact_live_vitals(vitals: list[dict]) -> list[dict]:
return [{"name": v.get("name"), "value": v.get("value")} for v in vitals]
def _resolve_stored_vital_label(vital: str) -> str | None:
"""Map a tool argument to the label used in SQLite snapshots."""
req = vital.strip()
if not req:
return None
if req in STORED_VITAL_LABELS:
return req
by_lower = {label.lower(): label for label in STORED_VITAL_LABELS}
if req.lower() in by_lower:
return by_lower[req.lower()]
key = req.upper().replace(" ", "_")
for label, command in get_poll_pids():
if command.name == key or label.lower() == req.lower():
return label
if hasattr(obd.commands, key):
cmd = getattr(obd.commands, key)
for label, command in get_poll_pids():
if command.name == cmd.name:
return label
return None
class GetStoredVitalHistoryInput(BaseModel):
vital: str = Field(
description=(
"Stored vital label or OBD command name "
f"(e.g. {', '.join(STORED_VITAL_LABELS[:6])}, …)."
),
)
count: int = Field(
default=30,
ge=1,
le=500,
description="Number of most recent stored samples (default 30). Ignored if start_iso/end_iso set.",
)
start_iso: str | None = Field(
default=None,
description="Optional UTC ISO-8601 range start (use with end_iso instead of count).",
)
end_iso: str | None = Field(
default=None,
description="Optional UTC ISO-8601 range end (use with start_iso).",
)
class ReadDtcCodesInput(BaseModel):
protocol: str = Field(
default="auto",
description="OBD-II protocol to use when reading codes (default: auto).",
)
class GetLiveVitalsInput(BaseModel):
vital: str | None = Field(
default=None,
description=(
"Optional OBD command name for one reading "
"(e.g. RPM, COOLANT_TEMP, MAF, SHORT_FUEL_TRIM_1, GET_DTC). "
"Omit for a compact summary of key vitals (not every PID)."
),
)
class GetRecentVitalsInput(BaseModel):
count: int = Field(
default=10,
ge=1,
le=600,
description=(
"Number of most recent vitals snapshots (default 10; use 5–15 for trends). "
"Returns compact key readings and DTCs per snapshot."
),
)
class GetVitalsRangeInput(BaseModel):
start_iso: str = Field(
description="Start of time range (UTC ISO-8601), e.g. 2026-06-10T14:00:00+00:00.",
)
end_iso: str = Field(
description="End of time range (UTC ISO-8601), e.g. 2026-06-10T14:30:00+00:00.",
)
@tool
def obd_vitals_reference() -> str:
"""Compact OBD vitals reference: stored labels, command names, and how to query live/historical data.
Returns a short guide (not the full 86-row catalog). Use get_live_vitals(vital=COMMAND)
for any supported PID. Call BEFORE investigating symptoms.
"""
logger.info("obd_vitals_reference invoked")
return format_obd_vitals_reference()
@tool
def check_obd_vitals_link() -> dict:
"""Verify the OBD connection is working and vitals can be read from the ECU.
Call after obd_vitals_reference and before get_live_vitals if readings fail
or before starting an investigation. Returns connection status and a sample RPM read.
"""
logger.info("check_obd_vitals_link invoked")
result = probe_obd_vitals()
logger.info(
"check_obd_vitals_link: can_receive_vitals=%s status=%s",
result.get("can_receive_vitals"),
result.get("status"),
)
return result
@tool
def get_car_info() -> dict:
"""Get vehicle make, model, year, and engine information."""
logger.info("get_car_info invoked")
result = {
"make": "Toyota",
"model": "Auris Hybrid",
"year": 2012,
"engine": "1.8L Hybrid",
}
logger.info("get_car_info result: %s", result)
return result
@tool(args_schema=ReadDtcCodesInput)
def read_dtc_codes(protocol: str = "auto") -> dict:
"""Read stored OBD-II diagnostic trouble codes (DTCs) from the vehicle ECU."""
logger.info("read_dtc_codes invoked (protocol=%s)", protocol)
with obd_session_lock:
codes = read_dtcs()
result = {
"protocol": protocol,
"codes": codes,
"count": len(codes),
}
logger.info("read_dtc_codes result: %s", result)
return result
@tool(args_schema=GetLiveVitalsInput)
def get_live_vitals(vital: str | None = None) -> dict:
"""Query the ECU now for live OBD-II sensor readings. Pass vital for one PID, or omit for key vitals."""
logger.info("get_live_vitals invoked (vital=%s)", vital)
try:
with obd_session_lock:
data = read_live_vitals(command=vital)
except ValueError as exc:
logger.warning("get_live_vitals failed: %s", exc)
return {
"command": vital,
"count": 0,
"vitals": [],
"dtcs": [],
"error": str(exc),
}
vitals = data["vitals"]
if vital is None:
vitals = _compact_live_vitals(vitals)
result = {
"command": data.get("command"),
"count": len(vitals),
"vitals": vitals,
"dtcs": data["dtcs"],
}
logger.info(
"get_live_vitals returned %d reading(s), %d DTC(s)",
result["count"],
len(result["dtcs"]),
)
return result
@tool(args_schema=GetRecentVitalsInput)
def get_recent_vitals(count: int = 10) -> dict:
"""Return recent stored OBD snapshots with timestamps, DTCs, and key sensor readings."""
logger.info("get_recent_vitals invoked (count=%s)", count)
snapshots = [_agent_snapshot(s) for s in get_recent(count)]
result = {"count": len(snapshots), "snapshots": snapshots}
logger.info("get_recent_vitals returned %d snapshot(s)", len(snapshots))
return result
@tool(args_schema=GetVitalsRangeInput)
def get_vitals_in_range(start_iso: str, end_iso: str) -> dict:
"""Return OBD vitals snapshots between two UTC timestamps (compact key readings + DTCs)."""
logger.info(
"get_vitals_in_range invoked (start=%s, end=%s)",
start_iso,
end_iso,
)
raw = get_range(start_iso, end_iso)
truncated, was_truncated = _cap_snapshots(raw)
snapshots = [_agent_snapshot(s) for s in truncated]
result = {
"start_iso": start_iso,
"end_iso": end_iso,
"count": len(snapshots),
"snapshots": snapshots,
"truncated": was_truncated,
"total_in_range": len(raw),
}
logger.info("get_vitals_in_range returned %d snapshot(s)", len(snapshots))
return result
@tool(args_schema=GetStoredVitalHistoryInput)
def get_stored_vital_history(
vital: str,
count: int = 30,
start_iso: str | None = None,
end_iso: str | None = None,
) -> dict:
"""Return chronological history of one vital from stored OBD snapshots (oldest → newest)."""
logger.info(
"get_stored_vital_history invoked (vital=%s, count=%s, start=%s, end=%s)",
vital,
count,
start_iso,
end_iso,
)
label = _resolve_stored_vital_label(vital)
if label is None:
return {
"vital": vital,
"count": 0,
"history": [],
"error": (
f"Unknown stored vital '{vital}'. "
f"Use a label like: {', '.join(STORED_VITAL_LABELS)}"
),
}
if start_iso and end_iso:
history = get_vital_history_in_range(label, start_iso, end_iso)
result = {
"vital": label,
"start_iso": start_iso,
"end_iso": end_iso,
"count": len(history),
"history": history,
}
elif start_iso or end_iso:
return {
"vital": label,
"count": 0,
"history": [],
"error": "Provide both start_iso and end_iso for a time range, or omit both to use count.",
}
else:
history = get_vital_history(label, count)
result = {
"vital": label,
"count": len(history),
"history": history,
}
logger.info(
"get_stored_vital_history returned %d point(s) for %s",
result["count"],
label,
)
return result
DIAGNOSTIC_TOOLS = [
obd_vitals_reference,
check_obd_vitals_link,
get_car_info,
read_dtc_codes,
get_live_vitals,
get_recent_vitals,
get_vitals_in_range,
get_stored_vital_history,
*DIAGNOSIS_TOOLS,
]