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
48 changes: 35 additions & 13 deletions custom_components/jev/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from .coordinator import JevRuntimeData
from .entity import build_device_info
from .interpret import build_questions, interpret
from .payload import payload_bytes
from .snapshot import async_snapshot

_LOGGER = logging.getLogger(__name__)
Expand All @@ -72,7 +73,9 @@
"already_on": "{name} is already on.",
"already_off": "{name} is already off.",
"query_not_found": "I could not find that.",
"budget_spent": "The daily token budget is spent, so I cannot do that today.",
"budget_spent": (
"Not enough of the daily token budget is left for that, so I cannot do it today."
),
"auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.",
"unavailable": "TypeSafe did not answer. Try again in a moment.",
}
Expand Down Expand Up @@ -154,23 +157,31 @@ async def _async_handle_message(
) -> conversation.ConversationResult:
runtime: JevRuntimeData = self._entry.runtime_data

# The budget covers voice as well as sensors, because a satellite that
# mishears a wake word all night is exactly the runaway it exists to stop.
runtime.usage.roll_over(dt_util.now().date())
if runtime.usage.would_exceed():
return await self._fall_back(
user_input, "the daily token budget is spent", "budget_spent"
)

snapshot = async_snapshot(self.hass, MAX_CONVERSATION_ENTITIES)
if not snapshot.entities:
return await self._fall_back(user_input, "no entities are exposed to Assist")

questions = build_questions(user_input.text, snapshot, MAX_CONVERSATION_ENTITIES)
state = snapshot.as_state() | {"command": user_input.text}

# The budget covers voice as well as sensors, because a satellite that
# mishears a wake word all night is exactly the runaway it exists to stop.
# The check is on this command's estimate, the same as a context's, so the
# last command of the day cannot take the total past the budget.
request_bytes = payload_bytes(state, questions, runtime.model)
estimate = runtime.usage.estimate_tokens(request_bytes)
if runtime.usage.would_exceed_with(estimate):
return await self._fall_back(
user_input,
f"the daily token budget has {runtime.usage.remaining()} tokens left "
f"and this command needs about {estimate}",
"budget_spent",
)

try:
response = await runtime.client.ask(state, questions)
with runtime.usage.reservation(estimate):
response = await runtime.client.ask(state, questions)
except JevAuthError as err:
_LOGGER.error("TypeSafe rejected the API key: %s", err)
self._entry.async_start_reauth(self.hass)
Expand All @@ -183,7 +194,7 @@ async def _async_handle_message(
user_input, f"TypeSafe did not answer: {err}", "unavailable"
)

runtime.usage.record(response.usage.input_tokens)
runtime.usage.record(response.usage.input_tokens, request_bytes)
runtime.model_version = response.model or runtime.model_version
runtime.usage.notify()

Expand Down Expand Up @@ -267,7 +278,14 @@ async def _fall_back(
agent = self._fallback_agent
_LOGGER.debug("falling back to %s because %s", agent or "nobody", why)
if agent is None:
return await self._speak(user_input, line)
# An error, as the default agent answers one. A satellite and the Assist
# dialog treat an action_done reply as a command that went through.
code = (
ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH
if line == "not_understood"
else ha_intent.IntentResponseErrorCode.FAILED_TO_HANDLE
)
return await self._speak(user_input, line, error=code)
result = await conversation.async_converse(
self.hass,
user_input.text,
Expand Down Expand Up @@ -316,13 +334,17 @@ async def _speak(
self,
user_input: conversation.ConversationInput,
key: str,
error: ha_intent.IntentResponseErrorCode | None = None,
**placeholders: str,
) -> conversation.ConversationResult:
"""Say one of our own lines, with its placeholders filled in."""
language = user_input.language or self.hass.config.language
text = (await self._lines(language))[key]
text = (await self._lines(language))[key].format(**placeholders)
response = ha_intent.IntentResponse(language=user_input.language)
response.async_set_speech(text.format(**placeholders))
if error is None:
response.async_set_speech(text)
else:
response.async_set_error(error, text)
return conversation.ConversationResult(
response=response, conversation_id=user_input.conversation_id
)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "The API answered, but not to the question that was asked.",
"preview_template_error": "The template does not render: {reason}",
"query_not_found": "I could not find that.",
"budget_spent": "The daily token budget is spent, so I cannot do that today.",
"budget_spent": "Not enough of the daily token budget is left for that, so I cannot do it today.",
"auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.",
"unavailable": "TypeSafe did not answer. Try again in a moment.",
"preview_over_budget": "The daily token budget is spent, so there is no trial answer. The question still saves.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "API odpovědělo, ale ne na otázku, která byla položena.",
"preview_template_error": "Šablona se nevykreslí: {reason}",
"query_not_found": "To jsem nenašel.",
"budget_spent": "Denní rozpočet tokenů je vyčerpán, takže to dnes udělat nemohu.",
"budget_spent": "Z denního rozpočtu tokenů na to nezbývá dost, takže to dnes udělat nemohu.",
"auth_failed": "TypeSafe odmítl klíč API. Zkontrolujte ho v nastavení Jev.",
"unavailable": "TypeSafe neodpověděl. Zkuste to za chvíli znovu.",
"preview_over_budget": "Denní rozpočet tokenů je vyčerpán, takže zkušební odpověď není. Otázka se přesto uloží.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "API'en svarede, men ikke på det spørgsmål, der blev stillet.",
"preview_template_error": "Skabelonen kan ikke gengives: {reason}",
"query_not_found": "Det kunne jeg ikke finde.",
"budget_spent": "Det daglige tokenbudget er brugt, så det kan jeg ikke gøre i dag.",
"budget_spent": "Der er ikke nok tilbage af det daglige tokenbudget til det, så det kan jeg ikke gøre i dag.",
"auth_failed": "TypeSafe afviste API-nøglen. Tjek den i indstillingerne for Jev.",
"unavailable": "TypeSafe svarede ikke. Prøv igen om et øjeblik.",
"preview_over_budget": "Det daglige token-budget er brugt op, så der er intet prøvesvar. Spørgsmålet bliver gemt alligevel.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "Die API hat geantwortet, aber nicht auf die gestellte Frage.",
"preview_template_error": "Die Vorlage lässt sich nicht rendern: {reason}",
"query_not_found": "Das konnte ich nicht finden.",
"budget_spent": "Das tägliche Token-Budget ist aufgebraucht, deshalb kann ich das heute nicht tun.",
"budget_spent": "Vom täglichen Token-Budget ist dafür nicht mehr genug übrig, deshalb kann ich das heute nicht tun.",
"auth_failed": "TypeSafe hat den API-Schlüssel abgelehnt. Prüfe ihn in den Jev-Einstellungen.",
"unavailable": "TypeSafe hat nicht geantwortet. Versuche es gleich noch einmal.",
"preview_over_budget": "Das Tagesbudget für Tokens ist aufgebraucht, es gibt also keine Probeantwort. Die Frage wird trotzdem gespeichert.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "The API answered, but not to the question that was asked.",
"preview_template_error": "The template does not render: {reason}",
"query_not_found": "I could not find that.",
"budget_spent": "The daily token budget is spent, so I cannot do that today.",
"budget_spent": "Not enough of the daily token budget is left for that, so I cannot do it today.",
"auth_failed": "TypeSafe rejected the API key. Check it in the Jev settings.",
"unavailable": "TypeSafe did not answer. Try again in a moment.",
"preview_over_budget": "The daily token budget is spent, so there is no trial answer. The question still saves.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "La API ha respondido, pero no a la pregunta que se le hizo.",
"preview_template_error": "La plantilla no se procesa: {reason}",
"query_not_found": "No he podido encontrarlo.",
"budget_spent": "El presupuesto diario de tokens se ha agotado, así que hoy no puedo hacerlo.",
"budget_spent": "No queda suficiente presupuesto diario de tokens para eso, así que hoy no puedo hacerlo.",
"auth_failed": "TypeSafe rechazó la clave de API. Revísala en los ajustes de Jev.",
"unavailable": "TypeSafe no respondió. Inténtalo de nuevo en un momento.",
"preview_over_budget": "El presupuesto diario de tokens está agotado, así que no hay respuesta de prueba. La pregunta sí se guarda.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "L'API a répondu, mais pas à la question qui a été posée.",
"preview_template_error": "Le rendu du modèle échoue : {reason}",
"query_not_found": "Je n'ai pas trouvé cela.",
"budget_spent": "Le budget quotidien de jetons est épuisé, je ne peux donc pas le faire aujourd'hui.",
"budget_spent": "Il ne reste pas assez de budget quotidien de jetons pour cela, je ne peux donc pas le faire aujourd'hui.",
"auth_failed": "TypeSafe a refusé la clé API. Vérifiez-la dans les paramètres de Jev.",
"unavailable": "TypeSafe n'a pas répondu. Réessayez dans un instant.",
"preview_over_budget": "Le budget quotidien de jetons est épuisé, il n'y a donc pas de réponse d'essai. La question est quand même enregistrée.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "L'API ha risposto, ma non alla domanda che è stata posta.",
"preview_template_error": "Il template non si elabora: {reason}",
"query_not_found": "Non sono riuscito a trovarlo.",
"budget_spent": "Il budget giornaliero di token è esaurito, quindi oggi non posso farlo.",
"budget_spent": "Non resta abbastanza budget giornaliero di token per questo, quindi oggi non posso farlo.",
"auth_failed": "TypeSafe ha rifiutato la chiave API. Controllala nelle impostazioni di Jev.",
"unavailable": "TypeSafe non ha risposto. Riprova tra un momento.",
"preview_over_budget": "Il budget giornaliero di token è esaurito, quindi non c'è una risposta di prova. La domanda viene comunque salvata.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "De API antwoordde, maar niet op de vraag die gesteld is.",
"preview_template_error": "De template rendert niet: {reason}",
"query_not_found": "Dat kon ik niet vinden.",
"budget_spent": "Het dagelijkse tokenbudget is op, dus dat kan ik vandaag niet doen.",
"budget_spent": "Er is niet genoeg over van het dagelijkse tokenbudget, dus dat kan ik vandaag niet doen.",
"auth_failed": "TypeSafe heeft de API-sleutel geweigerd. Controleer hem in de instellingen van Jev.",
"unavailable": "TypeSafe gaf geen antwoord. Probeer het zo nog eens.",
"preview_over_budget": "Het daglimiet voor tokens is op, dus er is geen proefantwoord. De vraag wordt wel opgeslagen.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "API odpowiedziało, ale nie na zadane pytanie.",
"preview_template_error": "Szablon się nie renderuje: {reason}",
"query_not_found": "Nie udało mi się tego znaleźć.",
"budget_spent": "Dzienny budżet tokenów się wyczerpał, więc dziś nie mogę tego zrobić.",
"budget_spent": "W dziennym budżecie tokenów nie zostało na to dość, więc dziś nie mogę tego zrobić.",
"auth_failed": "TypeSafe odrzucił klucz API. Sprawdź go w ustawieniach Jev.",
"unavailable": "TypeSafe nie odpowiedział. Spróbuj ponownie za chwilę.",
"preview_over_budget": "Dzienny limit tokenów jest wyczerpany, więc nie ma próbnej odpowiedzi. Pytanie i tak zostanie zapisane.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "A API respondeu, mas não à pergunta que foi feita.",
"preview_template_error": "O template não é renderizado: {reason}",
"query_not_found": "Não consegui encontrar isso.",
"budget_spent": "O orçamento diário de tokens acabou, então não posso fazer isso hoje.",
"budget_spent": "Não resta orçamento diário de tokens suficiente para isso, então não posso fazer isso hoje.",
"auth_failed": "A TypeSafe rejeitou a chave de API. Verifique-a nas configurações do Jev.",
"unavailable": "A TypeSafe não respondeu. Tente novamente em instantes.",
"preview_over_budget": "O orçamento diário de tokens acabou, então não há resposta de teste. A pergunta ainda é salva.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "API ответил, но не на тот вопрос, который был задан.",
"preview_template_error": "Шаблон не обрабатывается: {reason}",
"query_not_found": "Мне не удалось это найти.",
"budget_spent": "Дневной бюджет токенов исчерпан, поэтому сегодня я не могу это сделать.",
"budget_spent": "В дневном бюджете токенов на это не хватает, поэтому сегодня я не могу это сделать.",
"auth_failed": "TypeSafe отклонил ключ API. Проверьте его в настройках Jev.",
"unavailable": "TypeSafe не ответил. Попробуйте ещё раз чуть позже.",
"preview_over_budget": "Дневной бюджет токенов исчерпан, поэтому пробного ответа нет. Вопрос всё равно сохраняется.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "API:et svarade, men inte på frågan som ställdes.",
"preview_template_error": "Mallen renderas inte: {reason}",
"query_not_found": "Det kunde jag inte hitta.",
"budget_spent": "Den dagliga tokenbudgeten är slut, så det kan jag inte göra i dag.",
"budget_spent": "Det finns inte tillräckligt kvar av den dagliga tokenbudgeten för det, så det kan jag inte göra i dag.",
"auth_failed": "TypeSafe avvisade API-nyckeln. Kontrollera den i inställningarna för Jev.",
"unavailable": "TypeSafe svarade inte. Försök igen om en stund.",
"preview_over_budget": "Den dagliga budgeten för token är slut, så det finns inget provsvar. Frågan sparas ändå.",
Expand Down
2 changes: 1 addition & 1 deletion custom_components/jev/translations/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@
"preview_mismatched": "API 作出了回答,但回答的不是所提的那个问题。",
"preview_template_error": "模板无法渲染:{reason}",
"query_not_found": "我找不到那个。",
"budget_spent": "今天的令牌预算已用完,所以今天无法执行。",
"budget_spent": "今天剩余的令牌预算不足,所以无法执行。",
"auth_failed": "TypeSafe 拒绝了 API 密钥。请在 Jev 设置中检查。",
"unavailable": "TypeSafe 没有响应。请稍后再试。",
"preview_over_budget": "每日令牌预算已用完,因此没有试答结果。问题仍会保存。",
Expand Down
45 changes: 43 additions & 2 deletions tests/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
from homeassistant.core import Context, ServiceCall
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import intent as ha_intent
from homeassistant.setup import async_setup_component
from jevclient import ChoiceAnswer, NoulAnswer
from jevclient import ChoiceAnswer, NoulAnswer, Usage

from custom_components.jev.const import (
CONF_ALLOW_WHOLE_HOME,
Expand All @@ -26,6 +27,7 @@
)
from custom_components.jev.conversation import _render_state_answer
from custom_components.jev.interpret import find_brightness
from custom_components.jev.payload import payload_bytes

from .conftest import PROBE_TOKENS, build_response

Expand Down Expand Up @@ -197,6 +199,7 @@ async def test_a_compound_command_acts_on_nothing(hass, house, mock_client):

assert calls == []
assert "did not understand" in result.response.speech["plain"]["speech"]
assert result.response.error_code is ha_intent.IntentResponseErrorCode.NO_INTENT_MATCH


async def test_low_confidence_acts_on_nothing(hass, house, mock_client):
Expand Down Expand Up @@ -341,7 +344,45 @@ async def test_a_spent_budget_stops_voice_too(hass, house, mock_client):

assert mock_client.ask.await_count == 0
assert calls == []
assert "budget is spent" in result.response.speech["plain"]["speech"]
assert "budget is left" in result.response.speech["plain"]["speech"]
assert result.response.response_type is ha_intent.IntentResponseType.ERROR


async def test_a_command_that_would_pass_the_budget_is_not_sent(hass, house, mock_client):
# One token short of the budget. would_exceed() says there is room, and on
# 1.15 the command went through and ended the day about 1,000 tokens over.
hass.config_entries.async_update_entry(house, options={"daily_token_budget": 1000})
await hass.async_block_till_done()
house.runtime_data.usage.input_tokens = 999
mock_client.ask.reset_mock()

calls = []
hass.services.async_register("light", "turn_on", lambda call: calls.append(call))
result = await converse(hass, "kitchen light on")
await hass.async_block_till_done()

assert mock_client.ask.await_count == 0
assert calls == []
assert "budget is left" in result.response.speech["plain"]["speech"]
assert result.response.response_type is ha_intent.IntentResponseType.ERROR


async def test_a_voice_command_teaches_the_estimate(hass, house, mock_client):
# The estimate reads bytes per token from the last call it could measure. A
# voice command that did not report its size left the estimate on whatever
# the last context taught it, which is a different shape of request.
mock_client.ask.return_value = replace(
build_response(**answer_set()), usage=Usage(input_tokens=1371, output_tokens=42)
)
usage = house.runtime_data.usage

with patch.object(usage, "record", wraps=usage.record) as record:
await converse(hass, "kitchen light on")
await hass.async_block_till_done()

sent_state, sent_questions = mock_client.ask.await_args.args
sent = payload_bytes(sent_state, sent_questions, house.runtime_data.model)
record.assert_called_once_with(1371, sent)


async def test_a_rejected_key_is_said_out_loud(hass, house, mock_client):
Expand Down
Loading