Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions custom_components/jev/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@

from __future__ import annotations

import json
import logging
import re
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from typing import Literal
from typing import Any, Literal

from homeassistant.components import conversation
from homeassistant.components.conversation.models import AbstractConversationAgent
Expand All @@ -40,7 +41,7 @@
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util import language as language_util
from jevclient import JevAuthError, JevError
from jevclient import ChoiceAnswer, JevAuthError, JevError, JevResponse, NoulAnswer

from .const import (
CONF_ALLOW_WHOLE_HOME,
Expand Down Expand Up @@ -199,15 +200,26 @@ async def _async_handle_message(
runtime.usage.notify()

decision = interpret(response, user_input.text, snapshot, self._min_confidence)
runtime.conversation_traces.appendleft(
{
"text": user_input.text,
"latency_ms": response.latency_ms,
"input_tokens": response.usage.input_tokens,
"exposed_entities": len(snapshot.entities),
**asdict(decision),
}
)
trace = {
"text": user_input.text,
"latency_ms": response.latency_ms,
"input_tokens": response.usage.input_tokens,
"exposed_entities": len(snapshot.entities),
**asdict(decision),
}
runtime.conversation_traces.appendleft(trace)
# The pipeline records a chat log delta as an intent-progress event: the
# Assist dialog shows its thinking_content under the reply, and the run's
# debug events keep it. The delta goes to the listener only, not into the
# log, so a fallback agent reading this conversation never takes it for
# something said. It carries each answer's distribution, because "why did
# it pick the office light" is answered by the entity question and by
# nothing in the decision alone.
if chat_log.delta_listener is not None:
chat_log.delta_listener(
chat_log,
{"role": "assistant", "thinking_content": _reasoning(trace, response)},
)

if decision.already_satisfied is not None:
name, settled = decision.already_satisfied
Expand Down Expand Up @@ -350,6 +362,31 @@ async def _speak(
)


def _reasoning(trace: Mapping[str, Any], response: JevResponse) -> str:
"""The trace as lines a person reads in the Assist dialog."""
lines = [
f"Jev: {trace['action'] or 'no action'}, {trace['reason']}, "
f"confidence {trace['confidence']:.2f}",
f"Slots: {json.dumps(trace['slots'], ensure_ascii=False)}",
]
for key, answer in response.answers.items():
if isinstance(answer, ChoiceAnswer):
ranked = sorted((answer.probabilities or {}).items(), key=lambda kv: -kv[1])[
:3
]
spread = ", ".join(f"{k} {p:.2f}" for k, p in ranked)
lines.append(f"{key}: {answer.choice} {answer.confidence:.2f} ({spread})")
elif isinstance(answer, NoulAnswer):
lines.append(f"{key}: {answer.noul:.2f}")
else:
lines.append(f"{key}: {json.dumps(asdict(answer), ensure_ascii=False)}")
lines.append(
f"{response.model}, {trace['input_tokens']} input tokens, "
f"{trace['latency_ms']:.0f} ms, {trace['exposed_entities']} entities"
)
return "\n".join(lines)


# A template that compares the state against an English word writes the state word
# itself, in its own language. Hand it a translated one and every branch falls
# through. Measured against home-assistant-intents 2026.8.28: 3 of the 47 templates
Expand Down
68 changes: 68 additions & 0 deletions tests/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.core import Context, ServiceCall
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import chat_session
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import intent as ha_intent
from homeassistant.setup import async_setup_component
Expand Down Expand Up @@ -521,6 +522,73 @@ async def test_traces_are_bounded(hass, house, mock_client):
assert len(house.runtime_data.conversation_traces) == CONVERSATION_TRACE_LENGTH


async def converse_in_a_pipeline(hass, text):
"""Converse the way assist_pipeline does, with a listener on the chat log."""
deltas = []
with (
chat_session.async_get_chat_session(hass, None) as session,
conversation.async_get_chat_log(
hass,
session,
conversation.ConversationInput(
text=text,
context=Context(),
conversation_id=session.conversation_id,
device_id=None,
satellite_id=None,
language="en",
agent_id=AGENT,
),
chat_log_delta_listener=lambda _log, delta: deltas.append(delta),
) as chat_log,
):
result = await conversation.async_converse(
hass, text, session.conversation_id, Context(), "en", agent_id=AGENT
)
content = list(chat_log.content)
return result, deltas, content


async def test_the_assist_dialog_shows_what_jev_answered(hass, house, mock_client):
mock_client.ask.return_value = build_response(
**answer_set(
entity=ChoiceAnswer(
choice="light.kitchen",
probabilities={"light.kitchen": 0.8, "light.office": 0.2},
confidence=0.8,
)
)
)
_, deltas, content = await converse_in_a_pipeline(hass, "kitchen light on")

assert len(deltas) == 1
assert deltas[0]["role"] == "assistant"
shown = deltas[0]["thinking_content"]
assert "Jev: turn_on, ok, confidence 0.97" in shown
assert "entity: light.kitchen 0.80 (light.kitchen 0.80, light.office 0.20)" in shown
assert "321 input tokens" in shown
# Shown to the pipeline, never written into the conversation a fallback reads.
assert [c.role for c in content] == ["system", "user"]


async def test_the_assist_dialog_shows_why_jev_refused(hass, house, mock_client):
mock_client.ask.return_value = build_response(
**answer_set(compound=NoulAnswer(noul=0.95))
)
result, deltas, _ = await converse_in_a_pipeline(hass, "two things at once")

assert "several commands in one sentence" in deltas[0]["thinking_content"]
assert "compound: 0.95" in deltas[0]["thinking_content"]
assert result.response.response_type is ha_intent.IntentResponseType.ERROR


async def test_a_command_outside_a_pipeline_still_answers(hass, house, mock_client):
"""No listener, as from conversation.process: nothing to show it to."""
mock_client.ask.return_value = build_response(**answer_set())
result = await converse(hass, "kitchen light on")
assert result.response.response_type is not ha_intent.IntentResponseType.ERROR


@pytest.mark.parametrize(
("text", "expected"),
[
Expand Down
Loading