diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..2e00940 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @gritaro diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..0cc3ab5 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: +- package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + +- package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 0000000..a06bf95 --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,36 @@ +# These settings are synced to GitHub by https://probot.github.io/apps/settings/ + +repository: + name: ha-smartchain + description: "SmartChain — multi-provider LLM conversation agent for Home Assistant (GigaChat, YandexGPT, OpenAI) with tool calling and streaming" + homepage: https://github.com/gritaro/ha-smartchain + topics: homeassistant, hacs-integration, voice-assistant, langchain, gigachat, yandexgpt, openai, llm, smart-home, ai-agent + private: false + has_issues: true + has_projects: false + has_wiki: false + default_branch: main + allow_squash_merge: true + use_squash_pr_title_as_default: true + allow_merge_commit: false + allow_rebase_merge: true + delete_branch_on_merge: true + enable_vulnerability_alerts: true + +labels: +- name: "Feature Request" + color: "00ffbb" +- name: "Bug" + color: "e30000" +- name: "Wont Fix" + color: "ffffff" +- name: "Enhancement" + color: "48ff00" +- name: "Documentation" + color: "0077ff" +- name: "Help needed" + color: "fbca04" +- name: "dependencies" + color: "000000" +- name: "github_actions" + color: "000000" diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml new file mode 100644 index 0000000..85030c2 --- /dev/null +++ b/.github/workflows/cron.yaml @@ -0,0 +1,21 @@ +name: Cron HACS actions + +on: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v6" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/hacs.yaml b/.github/workflows/hacs.yaml deleted file mode 100644 index ea6cf6f..0000000 --- a/.github/workflows/hacs.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: HACS Action - -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" - -jobs: - hacs: - name: HACS Action - runs-on: "ubuntu-latest" - steps: - - name: HACS Action - uses: "hacs/action@main" - with: - category: "integration" diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml deleted file mode 100644 index 5f7a071..0000000 --- a/.github/workflows/hassfest.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: Validate with hassfest - -on: - push: - pull_request: - schedule: - - cron: '0 0 * * *' - -jobs: - validate: - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v3" - - uses: "home-assistant/actions/hassfest@master" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..b3eb8ba --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,40 @@ +name: Pull actions + +on: + pull_request: + +env: + PYTHON_VERSION: "3.12" + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v6" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + lint: + runs-on: "ubuntu-latest" + name: Lint with ruff + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv sync + - run: uv run pytest tests/ -v diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..044b124 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,44 @@ +name: Push actions + +on: + push: + branches: + - main + - rc-* + +env: + PYTHON_VERSION: "3.12" + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v6" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + lint: + runs-on: "ubuntu-latest" + name: Lint with ruff + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv run ruff check . + - run: uv run ruff format --check . + + test: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - uses: "actions/checkout@v6" + - uses: "astral-sh/setup-uv@v5" + - run: uv sync + - run: uv run pytest tests/ -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9c9721 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__ +.idea +/shelf/ +/workspace.xml +/httpRequests/ +/dataSources/ +/dataSources.local.xml +*.iws +*.iml +*.ipr +/.mcp.json +/.serena/ +.pytest_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..55460f3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.7 + hooks: + - id: ruff + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..00c2e8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to this project are documented in this file. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +project follows [Semantic Versioning](https://semver.org/). + +## [0.7.0] - 2026-03-10 + +### Changed +- **Project renamed: GigaChain -> SmartChain** — reflects multi-provider nature (not GigaChat-only) +- Domain: `gigachain` -> `smartchain` +- Entity classes: `GigaChainConversationEntity` -> `SmartChainConversationEntity` +- New GitHub repository: `ha-smartchain` +- HACS name: SmartChain +- Version bumped to 0.7.0 + +### Added +- **AI Task entity** — `SmartChainAITaskEntity` implements `ai_task.AITaskEntity` with `_async_generate_data()` for automation-driven text generation via `ai_task.generate_data` service +- Structured output support in AI Task (JSON parsing with `task.structure`) +- Tool calling support in AI Task (reuses conversation entity's LangChain integration) + +## [0.6.0] - 2026-03-10 + +### Added +- **Assist API for device control** — integration with HA LLM API (`async_provide_llm_data`) allows LLM to call Home Assistant services (turn on/off lights, locks, etc.) +- `llm_hass_api` option in Options Flow — select HA API for LLM (Assist, custom APIs) +- HA `llm.Tool` (voluptuous schema) -> LangChain tools conversion via `voluptuous_openapi` + `client.bind_tools()` +- Tool calling loop with `MAX_TOOL_ITERATIONS = 10` +- `tool_calls` in `AIMessageChunk` -> HA `ToolInput` in stream deltas +- `ToolResultContent` <-> LangChain `ToolMessage` conversion in `_chatlog_to_langchain()` + +### Changed +- `_async_handle_message` — uses `async_provide_llm_data` when LLM API configured, manual prompt otherwise +- Options Flow: `common_config_option_schema` takes `hass` to list available LLM APIs + +## [0.5.0] - 2026-03-10 + +### Added +- **Streaming responses** — `_attr_supports_streaming = True`, responses streamed via `ChatLog.async_add_delta_content_stream()` +- Async generator `_async_langchain_stream()` for `AIMessageChunk` -> HA delta dicts + +### Changed +- `client.invoke()` via `async_add_executor_job` replaced with `client.astream()` (async, no executor) + +## [0.4.0] - 2026-03-10 + +### Changed +- **ChatLog for history** — removed custom `OrderedDict`, uses native HA `ChatLog` +- **Migration to langchain-gigachat/langchain-openai** — proper package imports + +## [0.3.0] - 2026-03-10 + +### Added +- **Migration to ConversationEntity** — entity-based conversation agent with `_async_handle_message(user_input, chat_log)` + +## [0.2.0] - 2026-03-10 + +### Fixed +- Blocking LLM calls, deprecated LangChain API, memory leaks, model defaults + +### Removed +- Anyscale support completely removed + +## [0.1.8] - 2024-12-01 + +### Fixed +- Compatibility with Home Assistant 2024.12.1+ + +## [0.1.1] - 2024-03-01 + +### Added +- Initial release with GigaChat, YandexGPT support +- Config Flow and Options Flow +- Chat history and Jinja2 system prompts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b5278c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# SmartChain — Project Rules + +## Project Overview +SmartChain is a Home Assistant custom integration providing a multi-provider LLM conversation agent via LangChain. + +- **Domain**: `smartchain` +- **HA Platform**: `Platform.CONVERSATION` (+ `Platform.AI_TASK` planned) +- **Supported LLM providers**: GigaChat, YandexGPT, OpenAI +- **Planned providers**: Ollama, DeepSeek, Anthropic +- **GitHub**: https://github.com/dzerik/ha-smartchain + +## Architecture + +### Core Files +- `custom_components/smartchain/__init__.py` — entry setup, client creation +- `custom_components/smartchain/conversation.py` — `SmartChainConversationEntity` (streaming, tool calling) +- `custom_components/smartchain/ai_task.py` — `SmartChainAITaskEntity` (data generation) +- `custom_components/smartchain/config_flow.py` — Config Flow + Options Flow +- `custom_components/smartchain/client_util.py` — LLM client factory (`get_client`, `validate_client`) +- `custom_components/smartchain/const.py` — all constants, prompts, model lists + +### Key Patterns +- **Streaming**: `client.astream()` -> `_async_langchain_stream()` -> `chat_log.async_add_delta_content_stream()` +- **Tool calling**: HA `llm.Tool` -> `_ha_tool_to_dict()` -> `client.bind_tools()` -> loop until no `unresponded_tool_results` +- **ChatLog conversion**: `_chatlog_to_langchain()` converts HA ChatLog content to LangChain message list +- **System prompt**: With Assist API — `async_provide_llm_data()`, without — manual Jinja2 template + `DEFAULT_DEVICES_PROMPT` + +### Tests +- `tests/test_config_flow.py` — 11 config flow tests +- `tests/test_init.py` — 19 conversation entity tests +- `tests/test_setup.py` — 4 setup/unload tests +- Run: `python3 -m pytest tests/ -v` + +## Development Rules + +### Naming +- Entity classes: `SmartChain*Entity` (e.g., `SmartChainConversationEntity`) +- Imports: `from custom_components.smartchain.X import Y` +- Domain constant: `DOMAIN = "smartchain"` + +### Testing +- Always run tests before committing: `python3 -m pytest tests/ -v` +- All tests must pass +- Mock LLM clients with `MagicMock` + `astream` side_effect +- Use `_make_chat_log()` helper for mock ChatLog with streaming support + +### Dependencies +- `langchain-gigachat>=0.3.0` — GigaChat provider +- `langchain-openai>=0.3.0` — OpenAI provider +- `langchain-community>=0.4.0` — YandexGPT and others +- `home-assistant-intents` — language support +- `yandexcloud==0.295.0` — Yandex Cloud SDK + +### Version Policy +- Manifest version in `custom_components/smartchain/manifest.json` +- Follow semver: PATCH for fixes, MINOR for features, MAJOR for breaking changes +- Current: 0.7.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8738494 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 gritaro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README-ru.md b/README-ru.md new file mode 100644 index 0000000..8244256 --- /dev/null +++ b/README-ru.md @@ -0,0 +1,73 @@ +[![en](https://img.shields.io/badge/lang-en-red.svg)](https://github.com/gritaro/ha-smartchain/blob/main/README.md) +[![ru](https://img.shields.io/badge/lang-ru-green.svg)](https://github.com/gritaro/ha-smartchain/blob/main/README-ru.md) + +
+

SmartChain

+

Мультипровайдерный LLM-ассистент для Home Assistant

+
+ +[![HACS](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz) +[![GitHub release](https://img.shields.io/github/v/release/gritaro/ha-smartchain)](https://github.com/gritaro/ha-smartchain/releases) + +## Обзор + +SmartChain — кастомная интеграция Home Assistant, предоставляющая голосового/текстового ассистента на базе нескольких LLM-провайдеров через LangChain: + +- **GigaChat** (Сбер) — русскоязычная LLM +- **YandexGPT** — LLM от Яндекса +- **OpenAI** — GPT-4.1, GPT-4o, o3, o4-mini + +### Возможности + +- **Потоковые ответы** — ответы приходят токен за токеном в реальном времени +- **Assist API (tool calling)** — управление устройствами HA через LLM (свет, розетки, замки и т.д.) +- **История диалогов** — многоходовые разговоры с контекстом +- **Встроенный процессор команд HA** — фоллбек на нативные команды HA +- **Настраиваемый системный промпт** — Jinja2 шаблоны с контекстом устройств и зон +- **Несколько LLM-провайдеров** — переключение без потери конфигурации + +## Установка + +### Требования +- Home Assistant с установленным [HACS](https://hacs.xyz/) + +### Установка через HACS +1. Добавьте репозиторий как [пользовательский HACS репозиторий](https://hacs.xyz/docs/faq/custom_repositories) +2. Найдите "SmartChain" в HACS +3. Установите и перезапустите Home Assistant + +## Настройка + +### 1. Добавление интеграции +**Настройки → Устройства и службы → Добавить интеграцию → SmartChain** + +### 2. Выбор LLM-провайдера +Выберите GigaChat, YandexGPT или OpenAI и введите API-ключ. + +### 3. Параметры +- **Модель** — выбор из списка или ввод своего имени модели +- **Assist API** — управление устройствами через tool calling +- **Системный промпт** — настройка поведения ассистента (Jinja2) +- **Температура** — креативность ответов (0.0–1.0) +- **Макс. токенов** — ограничение длины ответа +- **История** — включение/отключение памяти диалога +- **Встроенные команды** — использование нативного процессора команд HA + +### Настройка провайдеров + +#### GigaChat +Зарегистрируйтесь на [developers.sber.ru](https://developers.sber.ru/studio) и получите авторизационные данные. + +#### YandexGPT +Создайте [сервисный аккаунт](https://cloud.yandex.com/ru/docs/iam/operations/sa/create) с ролью `ai.languageModels.user` и [API-ключ](https://cloud.yandex.com/ru/docs/iam/operations/api-key/create). + +#### OpenAI +Получите API-ключ на [platform.openai.com](https://platform.openai.com/account/api-keys) + +## Использование + +Создайте голосовой ассистент в настройках HA и выберите SmartChain как conversation agent. + +## Лицензия + +MIT diff --git a/README.md b/README.md index b57e052..68941bf 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,73 @@ -
+[![en](https://img.shields.io/badge/lang-en-green.svg)](https://github.com/gritaro/ha-smartchain/blob/main/README.md) +[![ru](https://img.shields.io/badge/lang-ru-red.svg)](https://github.com/gritaro/ha-smartchain/blob/main/README-ru.md) +
+

SmartChain

+

Multi-provider LLM conversation agent for Home Assistant

+
- - Logo - +[![HACS](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz) +[![GitHub release](https://img.shields.io/github/v/release/gritaro/ha-smartchain)](https://github.com/gritaro/ha-smartchain/releases) -

🦜️🔗 GigaChain (GigaChat + LangChain)

- +## Overview + +SmartChain is a Home Assistant custom integration that provides a voice/conversation assistant powered by multiple LLM providers through LangChain: -# Компонент GigaChain для Home Assistant -[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) +- **GigaChat** (Sber) — Russian-focused LLM +- **YandexGPT** — Yandex Cloud LLM +- **OpenAI** — GPT-4.1, GPT-4o, o3, o4-mini -Компонент реализует диалоговую систему Home Assistant для использования с языковыми моделями, поддерживаемыми фреймворком GigaChain. -В настоящее время поддерживается только интеграция с LMM GigaChat (русскоязычная нейросеть от Сбера) +### Key Features -## Установка -Устанавливается как и любая HACS интеграция. +- **Streaming responses** — real-time token-by-token output +- **Assist API (tool calling)** — control HA devices via LLM (lights, switches, locks, etc.) +- **Chat history** — multi-turn conversations with context +- **Builtin HA sentence processing** — fallback to native HA commands +- **Customizable system prompt** — Jinja2 templates with device/area context +- **Multiple LLM providers** — switch providers without losing configuration -### Необходимые требования -Для использования интеграции вам понадобится Home Assistant с установленным [HACS](https://hacs.xyz/) +## Installation -### Установка с использованием HACS -Найдите GigaChain в магазине HACS. Если интеграция не находится в магазине HACS, вы можете [добавить этот url как пользовательский репозиторий HACS](https://hacs.xyz/docs/faq/custom_repositories). +### Requirements +- Home Assistant with [HACS](https://hacs.xyz/) installed -[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/gritaro/gigachain) +### Install via HACS +1. Add this repository as a [custom HACS repository](https://hacs.xyz/docs/faq/custom_repositories) +2. Search for "SmartChain" in HACS +3. Install and restart Home Assistant -Перезапустите Home Assistant. +## Configuration -## Добавление интеграции +### 1. Add Integration +Go to **Settings → Devices & Services → Add Integration → SmartChain** -[![Open your Home Assistant instance and start setting up a new integration of a specific brand.](https://my.home-assistant.io/badges/brand.svg)](https://my.home-assistant.io/redirect/brand/?brand=+GigaChain) +### 2. Select LLM Provider +Choose GigaChat, YandexGPT, or OpenAI and provide API credentials. -После добавления настройте интеграцию. +### 3. Configure Options +- **Model** — select or type custom model name +- **Assist API** — enable device control via LLM tool calling +- **System Prompt** — customize the assistant's behavior (Jinja2 template) +- **Temperature** — control response creativity (0.0–1.0) +- **Max Tokens** — limit response length +- **Chat History** — enable/disable multi-turn memory +- **Builtin Sentences** — use HA's native command processor as fallback -## Настройки +### Provider Setup -### Авторизация запросов к GigaChat -Для авторизации запросов к GigaChat вам понадобится получить *авторизационные данные* для работы с GigaChat API. +#### GigaChat +Register at [developers.sber.ru](https://developers.sber.ru/studio) and get authorization credentials. -> [!NOTE] -> О том как получить авторизационные данные для доступа к GigaChat читайте в [официальной документации](https://developers.sber.ru/docs/ru/gigachat/api/integration). -> +#### YandexGPT +Create a [service account](https://cloud.yandex.com/en/docs/iam/operations/sa/create) with `ai.languageModels.user` role and generate an [API key](https://cloud.yandex.com/en/docs/iam/operations/api-key/create). -Authorization data +#### OpenAI +Get an API key at [platform.openai.com](https://platform.openai.com/account/api-keys) -### Конфигурация +## Usage -* Темплейт промпта -* Модель +Create a Voice Assistant in HA settings and select your SmartChain entity as the conversation agent. -## Использование в качестве диалоговой системы -Создайте и настройте новый голосовой ассистент: +## License -Voice Assistant +MIT diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..44affd5 --- /dev/null +++ b/TODO.md @@ -0,0 +1,32 @@ +# SmartChain — TODO + +## Phase 1 — Competitive parity + +- [x] v0.6 Assist API — device control via tool calling +- [x] v0.7 Rename GigaChain -> SmartChain + AI Task entity +- [ ] v0.8.1 Ollama — local models (T-Pro, Qwen, Llama, Home-3B) +- [ ] v0.8.2 DeepSeek — cheapest cloud provider +- [ ] v0.8.3 Anthropic — Claude via LangChain +- [ ] v0.9 Sub-entries — multiple agents with different models/prompts + +## Phase 2 — Differentiation + +- [ ] v1.0 Vision — camera image analysis (GigaChat 2.0, GPT-4o, Ollama) +- [ ] v1.1 Image generation — Kandinsky (GigaChat) + YandexART +- [ ] v1.2 MCP — connect external MCP servers as tools +- [ ] v1.3 State history — LLM analyzes past events and trends + +## Phase 3 — Leadership + +- [ ] v1.4 Multi-agent — Dispatcher + specialized agents (LangGraph) +- [ ] v1.5 Telegram bot — home control via Telegram +- [ ] v1.6 STT/TTS — Yandex SpeechKit, full voice pipeline +- [ ] v1.7 Skill system — loadable skills from YAML +- [ ] v1.8 Prompt caching — token savings on repeated requests + +## Technical debt + +- [ ] Tests: Options Flow (config_flow) +- [ ] Test: integration with real ChatLog (not mock) +- [ ] E2E test: tool calling loop +- [ ] HACS: verify compatibility and publish diff --git a/custom_components/gigachain/__init__.py b/custom_components/gigachain/__init__.py deleted file mode 100644 index 6b40da6..0000000 --- a/custom_components/gigachain/__init__.py +++ /dev/null @@ -1,104 +0,0 @@ -"""The GigaChain integration.""" -from __future__ import annotations -from abc import abstractmethod -import re -from homeassistant.components import conversation -from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - intent, - template, -) -from homeassistant.components.conversation import AgentManager, agent -from typing import Literal -from langchain.chat_models import GigaChat -from langchain.prompts.chat import ( - AIMessagePromptTemplate, - ChatPromptTemplate, - HumanMessagePromptTemplate, - SystemMessagePromptTemplate, -) -from langchain.schema import AIMessage, HumanMessage, SystemMessage -from homeassistant.util import ulid -from .const import ( - DOMAIN, - CONF_AUTH_DATA, - CONF_CHAT_MODEL, - DEFAULT_CHAT_MODEL, - CONF_PROMPT, - DEFAULT_PROMPT - ) -import logging - -LOGGER = logging.getLogger(__name__) - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Initialize GigaChain.""" - client = GigaChat(credentials=entry.data[CONF_AUTH_DATA], verify_ssl_certs=False) - models = client.get_models() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client - conversation.async_set_agent(hass, entry, GigaChatAI(hass, entry)) - return True - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload GigaChain.""" - hass.data[DOMAIN].pop(entry.entry_id) - conversation.async_unset_agent(hass, entry) - return True - -class GigaChatAI(conversation.AbstractConversationAgent): - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the agent.""" - self.hass = hass - self.entry = entry - self.history: dict[str, list[dict]] = {} - - @property - def supported_languages(self) -> list[str] | Literal["*"]: - """Return a list of supported languages.""" - return MATCH_ALL - - async def async_process( - self, user_input: agent.ConversationInput - ) -> agent.ConversationResult: - """Process a sentence.""" - raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - if user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - messages = self.history[conversation_id] - else: - conversation_id = ulid.ulid_now() - prompt = self._async_generate_prompt(raw_prompt) - messages = [ - SystemMessage( - content=prompt - ) - ] - - messages.append(HumanMessage(content=user_input.text)) - client = self.hass.data[DOMAIN][self.entry.entry_id] - client.model = model - - res = client(messages) - messages.append(res) - self.history[conversation_id] = messages - - response = intent.IntentResponse(language=user_input.language) - response.async_set_speech(res.content) - return agent.ConversationResult( - conversation_id=conversation_id, - response=response - ) - - def _async_generate_prompt(self, raw_prompt: str) -> str: - """Generate a prompt for the user.""" - return template.Template(raw_prompt, self.hass).async_render( - { - "ha_name": self.hass.config.location_name, - }, - parse_result=False, - ) diff --git a/custom_components/gigachain/__pycache__/__init__.cpython-312.pyc b/custom_components/gigachain/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f53ea50..0000000 Binary files a/custom_components/gigachain/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/custom_components/gigachain/__pycache__/config_flow.cpython-312.pyc b/custom_components/gigachain/__pycache__/config_flow.cpython-312.pyc deleted file mode 100644 index dddf035..0000000 Binary files a/custom_components/gigachain/__pycache__/config_flow.cpython-312.pyc and /dev/null differ diff --git a/custom_components/gigachain/__pycache__/const.cpython-312.pyc b/custom_components/gigachain/__pycache__/const.cpython-312.pyc deleted file mode 100644 index 340af6b..0000000 Binary files a/custom_components/gigachain/__pycache__/const.cpython-312.pyc and /dev/null differ diff --git a/custom_components/gigachain/config_flow.py b/custom_components/gigachain/config_flow.py deleted file mode 100644 index d1571ed..0000000 --- a/custom_components/gigachain/config_flow.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Config flow for GigaChain integration.""" -from __future__ import annotations - -from typing import Any - -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult -import types -from types import MappingProxyType -from homeassistant.helpers.selector import ( - NumberSelector, - NumberSelectorConfig, - TemplateSelector, -) -from .const import ( - CONF_AUTH_DATA, - CONF_CHAT_MODEL, - CONF_PROMPT, - DEFAULT_CHAT_MODEL, - DEFAULT_PROMPT, - DOMAIN -) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_AUTH_DATA): str - } -) - -DEFAULT_OPTIONS = types.MappingProxyType( - { - CONF_PROMPT: DEFAULT_PROMPT, - CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, - } -) - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for GigaChain.""" - - VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - - unique_id = "GigaChat" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=unique_id, data=user_input) - - @staticmethod - def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: - """Create the options flow.""" - return OptionsFlow(config_entry) - -class OptionsFlow(config_entries.OptionsFlow): - """GigaChain config flow options handler.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(title="GigaChat", data=user_input) - schema = gigachat_config_option_schema(self.config_entry.options) - return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), - ) - -def gigachat_config_option_schema(options: MappingProxyType[str, Any]) -> dict: - """Return a schema for GigaChain completion options.""" - if not options: - options = DEFAULT_OPTIONS - return { - vol.Optional( - CONF_PROMPT, - description={"suggested_value": options[CONF_PROMPT]}, - default=DEFAULT_PROMPT, - ): TemplateSelector(), - vol.Optional( - CONF_CHAT_MODEL, - description={ - # New key in HA 2023.4 - "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - }, - default=DEFAULT_CHAT_MODEL, - ): str, - } diff --git a/custom_components/gigachain/const.py b/custom_components/gigachain/const.py deleted file mode 100644 index 36b9bc6..0000000 --- a/custom_components/gigachain/const.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Constants for the GigaChain integration.""" - -DOMAIN = "gigachain" -CONF_AUTH_DATA = "auth_data" -CONF_PROMPT = "prompt" -DEFAULT_PROMPT = """Ты HAL 9000, компьютер из цикла произведений «Космическая одиссея» Артура Кларка, обладающий способностью к самообучению. -Мы находимся в умном доме под управлением системы Home Assistant. -В доме есть следующие помещения и устройства: -{%- for area in areas() %} - {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area) -%} - {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} - {%- if not area_info.printed %} - -{{ area_name(area) }}: - {%- set area_info.printed = true %} - {%- endif %} -- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} - {%- endif %} - {%- endfor %} -{%- endfor %} -Когда отвечаешь, обращайся к собеседнику по имени Дэйв. -""" - -CONF_CHAT_MODEL = "model" -#GigaChat-Plus,GigaChat-Pro,GigaChat:latest -DEFAULT_CHAT_MODEL = "GigaChat" diff --git a/custom_components/gigachain/manifest.json b/custom_components/gigachain/manifest.json deleted file mode 100644 index 28cb0f1..0000000 --- a/custom_components/gigachain/manifest.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "domain": "gigachain", - "name": "GigaChain", - "codeowners": [ - "@gritaro" - ], - "config_flow": true, - "dependencies": [ - "conversation" - ], - "documentation": "https://github.com/gritaro/gigachain", - "homekit": {}, - "integration_type": "service", - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/gritaro/gigachain/issues", - "requirements": [ - "gigachain", - "yandexcloud" - ], - "version": "0.1.0" -} diff --git a/custom_components/gigachain/strings.json b/custom_components/gigachain/strings.json deleted file mode 100644 index cd4bc14..0000000 --- a/custom_components/gigachain/strings.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "GigaChain configuration", - "data": { - "auth_data": "Authorization data" - } - } - }, - "abort": { - "already_configured": "Already configured" - } - }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Prompt Template", - "model": "Completion Model" - } - } - } - } -} diff --git a/custom_components/gigachain/translations/en.json b/custom_components/gigachain/translations/en.json deleted file mode 100644 index cd4bc14..0000000 --- a/custom_components/gigachain/translations/en.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "GigaChain configuration", - "data": { - "auth_data": "Authorization data" - } - } - }, - "abort": { - "already_configured": "Already configured" - } - }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Prompt Template", - "model": "Completion Model" - } - } - } - } -} diff --git a/custom_components/gigachain/translations/ru.json b/custom_components/gigachain/translations/ru.json deleted file mode 100644 index 4ff4e76..0000000 --- a/custom_components/gigachain/translations/ru.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "GigaChain конфигурация", - "data": { - "auth_data": "Авторизационные данные" - } - } - }, - "abort": { - "already_configured": "Нельзя настроить более одной интеграции" - } - }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Промпт темплейт", - "model": "Модель" - } - } - } - } -} diff --git a/custom_components/smartchain/__init__.py b/custom_components/smartchain/__init__.py new file mode 100644 index 0000000..455fa8c --- /dev/null +++ b/custom_components/smartchain/__init__.py @@ -0,0 +1,60 @@ +"""The SmartChain integration.""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .client_util import get_client +from .const import ( + CONF_CHAT_MODEL, + CONF_CHAT_MODEL_USER, + CONF_ENGINE, + CONF_MAX_TOKENS, + CONF_TEMPERATURE, + DEFAULT_TEMPERATURE, + ID_GIGACHAT, +) + +LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.CONVERSATION, Platform.AI_TASK] + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Initialize SmartChain.""" + engine = entry.data.get(CONF_ENGINE) or ID_GIGACHAT + model = entry.options.get(CONF_CHAT_MODEL_USER) + if not model or not model.strip(): + model = entry.options.get(CONF_CHAT_MODEL) + temperature = entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + max_tokens = entry.options.get(CONF_MAX_TOKENS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + common_args = { + "verbose": False, + "model": model, + } + if temperature is not None: + common_args["temperature"] = temperature + if max_tokens is not None: + common_args["max_tokens"] = max_tokens + + client = await get_client(hass, engine, entry, common_args) + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload SmartChain.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/smartchain/ai_task.py b/custom_components/smartchain/ai_task.py new file mode 100644 index 0000000..41ca2e5 --- /dev/null +++ b/custom_components/smartchain/ai_task.py @@ -0,0 +1,99 @@ +"""AI Task entity for SmartChain integration.""" + +import logging +from typing import Any + +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import MAX_TOOL_ITERATIONS +from .conversation import ( + _async_langchain_stream, + _chatlog_to_langchain, + _ha_tool_to_dict, +) + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AI Task entity.""" + async_add_entities([SmartChainAITaskEntity(config_entry)]) + + +class SmartChainAITaskEntity(ai_task.AITaskEntity): + """SmartChain AI Task entity for data generation.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self.entry = entry + self._attr_unique_id = f"{entry.entry_id}_ai_task" + self._attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + client = self.entry.runtime_data + tools: list[dict[str, Any]] = ( + [_ha_tool_to_dict(tool) for tool in chat_log.llm_api.tools] + if chat_log.llm_api + else [] + ) + bound_client = client.bind_tools(tools) if tools else client + + for _iteration in range(MAX_TOOL_ITERATIONS): + messages = _chatlog_to_langchain(chat_log) + + try: + async for _content in chat_log.async_add_delta_content_stream( + self.entity_id, + _async_langchain_stream(bound_client, messages), + ): + pass + except Exception as err: + LOGGER.exception("AI Task error: %s", type(err)) + raise HomeAssistantError(f"AI Task error: {err}") from err + + if not chat_log.unresponded_tool_results: + break + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if task.structure: + from homeassistant.util.json import json_loads + from json import JSONDecodeError + + try: + data = json_loads(text) + except JSONDecodeError as err: + LOGGER.error("Failed to parse structured response: %s", err) + raise HomeAssistantError( + "Failed to parse structured AI Task response" + ) from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) diff --git a/custom_components/smartchain/client_util.py b/custom_components/smartchain/client_util.py new file mode 100644 index 0000000..a1a0184 --- /dev/null +++ b/custom_components/smartchain/client_util.py @@ -0,0 +1,85 @@ +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from langchain_core.messages import SystemMessage +from langchain_community.chat_models import ChatYandexGPT +from langchain_gigachat import GigaChat +from langchain_openai import ChatOpenAI + +from .const import ( + CONF_API_KEY, + CONF_ENGINE, + CONF_FOLDER_ID, + CONF_PROFANITY, + CONF_SKIP_VALIDATION, + CONF_VERIFY_SSL, + DEFAULT_PROFANITY, + DEFAULT_VERIFY_SSL, + ID_GIGACHAT, + ID_YANDEX_GPT, + ID_OPENAI, + DEFAULT_MODEL, +) + +LOGGER = logging.getLogger(__name__) + + +async def validate_client( + hass: HomeAssistant, + user_input: dict, +) -> None: + """Validate LLM client connection.""" + if user_input.get(CONF_SKIP_VALIDATION): + return + engine = user_input.get(CONF_ENGINE) or ID_GIGACHAT + if engine == ID_GIGACHAT: + client = GigaChat( + max_tokens=10, + verbose=False, + credentials=user_input[CONF_API_KEY], + verify_ssl_certs=False, + ) + elif engine == ID_YANDEX_GPT: + client = ChatYandexGPT( + max_tokens=10, + max_retries=2, + api_key=user_input[CONF_API_KEY], + folder_id=user_input[CONF_FOLDER_ID], + ) + else: + client = ChatOpenAI( + max_tokens=10, + model=DEFAULT_MODEL[ID_OPENAI], + openai_api_key=user_input[CONF_API_KEY], + ) + await hass.async_add_executor_job(client.invoke, [SystemMessage(content="{}")]) + + +async def get_client( + hass: HomeAssistant, + engine: str, + entry: ConfigEntry, + common_args: dict, +): + """Create LLM client based on engine type.""" + if engine == ID_GIGACHAT: + common_args["credentials"] = entry.data[CONF_API_KEY] + common_args["verify_ssl_certs"] = entry.options.get( + CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL + ) + common_args["profanity_check"] = entry.options.get( + CONF_PROFANITY, DEFAULT_PROFANITY + ) + client = GigaChat(**common_args) + elif engine == ID_YANDEX_GPT: + common_args["api_key"] = entry.data[CONF_API_KEY] + common_args["folder_id"] = entry.data[CONF_FOLDER_ID] + common_args["max_retries"] = 2 + client = ChatYandexGPT(**common_args) + else: + if common_args["model"] is None: + common_args["model"] = DEFAULT_MODEL[ID_OPENAI] + common_args["openai_api_key"] = entry.data[CONF_API_KEY] + client = ChatOpenAI(**common_args) + return client diff --git a/custom_components/smartchain/config_flow.py b/custom_components/smartchain/config_flow.py new file mode 100644 index 0000000..51f111c --- /dev/null +++ b/custom_components/smartchain/config_flow.py @@ -0,0 +1,310 @@ +"""Config flow for SmartChain integration.""" + +from __future__ import annotations + +import logging +from types import MappingProxyType +from typing import Any + +import voluptuous as vol +from gigachat.exceptions import ResponseError +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.core import callback +from homeassistant.helpers import selector +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + SelectSelectorMode, + TemplateSelector, +) +from httpx import ConnectError + +from homeassistant.helpers import llm + +from .client_util import validate_client +from .const import ( + CONF_API_KEY, + CONF_CHAT_MODEL, + CONF_CHAT_MODEL_USER, + CONF_ENGINE, + CONF_ENGINE_OPTIONS, + CONF_FOLDER_ID, + CONF_LLM_HASS_API, + CONF_MAX_TOKENS, + CONF_PROFANITY, + CONF_PROMPT, + CONF_SKIP_VALIDATION, + CONF_TEMPERATURE, + CONF_VERIFY_SSL, + DEFAULT_CHAT_MODEL, + DEFAULT_VERIFY_SSL, + ENGINE_MODELS, + DEFAULT_PROFANITY, + DEFAULT_PROMPT, + DEFAULT_SKIP_VALIDATION, + DEFAULT_TEMPERATURE, + DOMAIN, + ID_GIGACHAT, + ID_OPENAI, + ID_YANDEX_GPT, + UNIQUE_ID, + CONF_PROCESS_BUILTIN_SENTENCES, + DEFAULT_PROCESS_BUILTIN_SENTENCES, + CONF_CHAT_HISTORY, + DEFAULT_CHAT_HISTORY, + UNIQUE_ID_GIGACHAT, +) + +LOGGER = logging.getLogger(__name__) + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENGINE): selector.SelectSelector( + selector.SelectSelectorConfig(options=CONF_ENGINE_OPTIONS), + ), + } +) +STEP_API_KEY_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_SKIP_VALIDATION, default=DEFAULT_SKIP_VALIDATION): bool, + } +) +STEP_YANDEXGPT_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_FOLDER_ID): str, + vol.Optional(CONF_SKIP_VALIDATION, default=DEFAULT_SKIP_VALIDATION): bool, + } +) + +ENGINE_SCHEMA = { + ID_GIGACHAT: STEP_API_KEY_SCHEMA, + ID_YANDEX_GPT: STEP_YANDEXGPT_SCHEMA, + ID_OPENAI: STEP_API_KEY_SCHEMA, +} + +DEFAULT_OPTIONS = MappingProxyType( + { + CONF_PROMPT: DEFAULT_PROMPT, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, + CONF_CHAT_MODEL_USER: DEFAULT_CHAT_MODEL, + CONF_PROCESS_BUILTIN_SENTENCES: DEFAULT_PROCESS_BUILTIN_SENTENCES, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SmartChain.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=STEP_USER_SCHEMA) + + engine = user_input[CONF_ENGINE] + unique_id = UNIQUE_ID[engine] + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_show_form(step_id=engine, data_schema=ENGINE_SCHEMA[engine]) + + async def async_step_gigachat( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + return await self._common_model_async_step(ID_GIGACHAT, user_input) + + async def async_step_yandexgpt( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + return await self._common_model_async_step(ID_YANDEX_GPT, user_input) + + async def async_step_openai( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + return await self._common_model_async_step(ID_OPENAI, user_input) + + async def _common_model_async_step( + self, engine: str, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + if user_input is None: + return self.async_show_form( + step_id=engine, data_schema=ENGINE_SCHEMA[engine] + ) + + errors: dict[str, str] = {} + user_input[CONF_ENGINE] = engine + unique_id = UNIQUE_ID[engine] + try: + await validate_client(self.hass, user_input) + except ConnectError: + errors["base"] = "cannot_connect" + except ResponseError: + errors["base"] = "invalid_response" + except Exception as inst: + LOGGER.exception("Unexpected exception %s", type(inst)) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=unique_id, data=user_input) + + return self.async_show_form( + step_id=engine, data_schema=ENGINE_SCHEMA[engine], errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """SmartChain config flow options handler.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + unique_id = self.config_entry.unique_id + schema = common_config_option_schema( + self.hass, unique_id, self.config_entry.options + ) + if user_input is not None: + model = user_input.get(CONF_CHAT_MODEL_USER) + if not model or not model.strip(): + model = user_input.get(CONF_CHAT_MODEL) + if not model or not model.strip(): + return self.async_show_form( + step_id="init", + data_schema=schema, + errors={"base": "model_required"}, + ) + + # Remove empty LLM API selection + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + + return self.async_create_entry(title=unique_id, data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=schema, + ) + + +def common_config_option_schema( + hass, unique_id: str, options: MappingProxyType[str, Any] +) -> vol.Schema: + """Return a schema for SmartChain completion options.""" + if not options: + options = DEFAULT_OPTIONS + + hass_apis: list[selector.SelectOptionDict] = [ + selector.SelectOptionDict(value=api.id, label=api.name) + for api in llm.async_get_apis(hass) + ] + + schema = vol.Schema( + { + vol.Optional( + CONF_CHAT_MODEL, + description={ + "suggested_value": options.get(CONF_CHAT_MODEL), + }, + default="none", + ): selector.SelectSelector( + selector.SelectSelectorConfig( + mode=SelectSelectorMode("dropdown"), + options=ENGINE_MODELS[unique_id], + ), + ), + vol.Optional( + CONF_CHAT_MODEL_USER, + description={"suggested_value": options.get(CONF_CHAT_MODEL_USER)}, + ): str, + vol.Optional( + CONF_LLM_HASS_API, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=hass_apis, + multiple=True, + mode=SelectSelectorMode("dropdown"), + ), + ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get(CONF_PROMPT, DEFAULT_PROMPT) + }, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_TEMPERATURE, + description={ + "suggested_value": options.get( + CONF_TEMPERATURE, DEFAULT_TEMPERATURE + ) + }, + default=DEFAULT_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + ): int, + vol.Optional( + CONF_PROCESS_BUILTIN_SENTENCES, + description={ + "suggested_value": options.get( + CONF_PROCESS_BUILTIN_SENTENCES, + DEFAULT_PROCESS_BUILTIN_SENTENCES, + ) + }, + default=DEFAULT_PROCESS_BUILTIN_SENTENCES, + ): bool, + vol.Optional( + CONF_CHAT_HISTORY, + description={ + "suggested_value": options.get( + CONF_CHAT_HISTORY, DEFAULT_CHAT_HISTORY + ) + }, + default=DEFAULT_CHAT_HISTORY, + ): bool, + } + ) + if unique_id == UNIQUE_ID_GIGACHAT: + schema = schema.extend( + { + vol.Optional( + CONF_PROFANITY, + description={ + "suggested_value": options.get( + CONF_PROFANITY, DEFAULT_PROFANITY + ) + }, + default=DEFAULT_PROFANITY, + ): bool, + vol.Optional( + CONF_VERIFY_SSL, + description={ + "suggested_value": options.get( + CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL + ) + }, + default=DEFAULT_VERIFY_SSL, + ): bool, + } + ) + return schema diff --git a/custom_components/smartchain/const.py b/custom_components/smartchain/const.py new file mode 100644 index 0000000..3ab1be7 --- /dev/null +++ b/custom_components/smartchain/const.py @@ -0,0 +1,108 @@ +"""Constants for the SmartChain integration.""" + +from homeassistant.helpers import selector + +DOMAIN = "smartchain" +CONF_ENGINE = "engine" +CONF_CHAT_MODEL = "model" +CONF_CHAT_MODEL_USER = "model_user" +DEFAULT_CHAT_MODEL = "" +CONF_TEMPERATURE = "temperature" +DEFAULT_TEMPERATURE = 0.1 +CONF_PROFANITY = "profanity" +DEFAULT_PROFANITY = False +CONF_VERIFY_SSL = "verify_ssl" +DEFAULT_VERIFY_SSL = False +CONF_MAX_TOKENS = "max_tokens" +CONF_SKIP_VALIDATION = "skip_validation" +DEFAULT_SKIP_VALIDATION = False +CONF_PROCESS_BUILTIN_SENTENCES = "process_builtin_sentences" +DEFAULT_PROCESS_BUILTIN_SENTENCES = True +CONF_CHAT_HISTORY = "chat_history" +DEFAULT_CHAT_HISTORY = True +CONF_PROMPT = "prompt" +DEFAULT_PROMPT = """You are a smart home voice assistant {{ ha_name }} powered by Home Assistant. +Answer truthfully and to the point. Answer in plain text, briefly and clearly. +Answer in the user's language.""" + +DEFAULT_DEVICES_PROMPT = """ +The following rooms, devices and sensors are available in the home: +{%- for area in areas() %} + {%- set area_info = namespace(printed=false) %} + {%- for device in area_devices(area) -%} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} + {%- if not area_info.printed %} + +{{ area_name(area) }}: + {%- set area_info.printed = true %} + {%- endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} + + {%- for entity_id in device_entities(device) %} + {%- set entity_domain = entity_id.split('.')[0] %} + {%- set dc = state_attr(entity_id, 'device_class') %} + {%- set friendly = state_attr(entity_id, 'friendly_name') %} + {%- set entity_state = states(entity_id) %} + {%- if entity_state and entity_state != 'unavailable' %} + - {{ entity_id }} ({{ entity_domain }}{% if dc %}, {{ dc }}{% endif %}): {{ entity_state }}{% if state_attr(entity_id, 'unit_of_measurement') %} {{ state_attr(entity_id, 'unit_of_measurement') }}{% endif %} + + {%- endif %} + {%- endfor %} + {%- endif %} + {%- endfor %} +{%- endfor %}""" + +ID_GIGACHAT = "gigachat" +ID_YANDEX_GPT = "yandexgpt" +ID_OPENAI = "openai" +UNIQUE_ID_GIGACHAT = "GigaChat" +UNIQUE_ID_YANDEX_GPT = "YandexGPT" +UNIQUE_ID_OPENAI = "OpenAI" + +UNIQUE_ID = { + ID_GIGACHAT: UNIQUE_ID_GIGACHAT, + ID_YANDEX_GPT: UNIQUE_ID_YANDEX_GPT, + ID_OPENAI: UNIQUE_ID_OPENAI, +} + +CONF_ENGINE_OPTIONS = [ + selector.SelectOptionDict(value=ID_GIGACHAT, label=UNIQUE_ID_GIGACHAT), + selector.SelectOptionDict(value=ID_YANDEX_GPT, label=UNIQUE_ID_YANDEX_GPT), + selector.SelectOptionDict(value=ID_OPENAI, label=UNIQUE_ID_OPENAI), +] +MODELS_GIGACHAT = [ + "", + "GigaChat", + "GigaChat:latest", + "GigaChat-Plus", + "GigaChat-Pro", + "GigaChat-Max", +] +DEFAULT_MODELS_YANDEX_GPT = ["", "YandexGPT", "YandexGPT Lite", "Summary"] +MODELS_OPENAI = [ + "", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "o3", + "o3-mini", + "o4-mini", +] +ENGINE_MODELS = { + UNIQUE_ID_GIGACHAT: MODELS_GIGACHAT, + UNIQUE_ID_YANDEX_GPT: DEFAULT_MODELS_YANDEX_GPT, + UNIQUE_ID_OPENAI: MODELS_OPENAI, +} +DEFAULT_MODEL = { + ID_GIGACHAT: None, + ID_OPENAI: "gpt-4.1-mini", + ID_YANDEX_GPT: None, +} + +CONF_LLM_HASS_API = "llm_hass_api" +CONF_API_KEY = "api_key" +CONF_FOLDER_ID = "folder_id" + +MAX_TOOL_ITERATIONS = 10 diff --git a/custom_components/smartchain/conversation.py b/custom_components/smartchain/conversation.py new file mode 100644 index 0000000..36e302e --- /dev/null +++ b/custom_components/smartchain/conversation.py @@ -0,0 +1,237 @@ +"""Conversation entity for SmartChain integration.""" + +import json +import logging +from collections.abc import AsyncIterable +from typing import Any, Literal + +import voluptuous_openapi +from home_assistant_intents import get_languages +from homeassistant.components import conversation +from homeassistant.components.conversation import ( + ChatLog, + ConversationEntity, + ConversationInput, + ConversationResult, +) +from homeassistant.components.conversation.chat_log import ( + AssistantContent, + SystemContent, + ToolResultContent, + UserContent, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers import intent, llm, template +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from langchain_core.messages import ( + AIMessage, + BaseMessage, + HumanMessage, + SystemMessage, + ToolMessage, +) + +from .const import ( + CONF_CHAT_HISTORY, + CONF_LLM_HASS_API, + CONF_PROCESS_BUILTIN_SENTENCES, + CONF_PROMPT, + DEFAULT_CHAT_HISTORY, + DEFAULT_DEVICES_PROMPT, + DEFAULT_PROCESS_BUILTIN_SENTENCES, + DEFAULT_PROMPT, + DOMAIN, + MAX_TOOL_ITERATIONS, +) + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up conversation entity.""" + async_add_entities([SmartChainConversationEntity(config_entry)]) + + +def _ha_tool_to_dict(tool: llm.Tool) -> dict[str, Any]: + """Convert HA llm.Tool to dict for LangChain bind_tools.""" + parameters = voluptuous_openapi.convert(tool.parameters) + return { + "name": tool.name, + "description": tool.description or "", + "parameters": parameters, + } + + +class SmartChainConversationEntity(ConversationEntity): + """SmartChain conversation entity using ConversationEntity API.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_supports_streaming = True + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the entity.""" + self.entry = entry + self._attr_unique_id = entry.entry_id + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return get_languages() + + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Handle a conversation message via ChatLog API.""" + options = self.entry.options + llm_hass_api = options.get(CONF_LLM_HASS_API) + user_prompt = options.get(CONF_PROMPT, DEFAULT_PROMPT) + + if llm_hass_api: + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + llm_hass_api, + user_prompt, + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + else: + raw_prompt = user_prompt + DEFAULT_DEVICES_PROMPT + prompt = template.Template(raw_prompt, self.hass).async_render( + {"ha_name": self.hass.config.location_name}, + parse_result=False, + ) + chat_log.content[0] = SystemContent(content=prompt) + + use_builtin = options.get( + CONF_PROCESS_BUILTIN_SENTENCES, DEFAULT_PROCESS_BUILTIN_SENTENCES + ) + if use_builtin and not llm_hass_api: + from homeassistant.components.conversation import agent_manager + + default_agent = agent_manager.async_get_agent(self.hass, None) + default_response = await default_agent.async_process(user_input) + + if default_response.response.intent: + speech = default_response.response.speech.get("plain", {}).get( + "speech", "" + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id=user_input.agent_id, + content=speech, + ) + ) + return default_response + + client = self.entry.runtime_data + tools = ( + [_ha_tool_to_dict(tool) for tool in chat_log.llm_api.tools] + if chat_log.llm_api + else [] + ) + bound_client = client.bind_tools(tools) if tools else client + + for _iteration in range(MAX_TOOL_ITERATIONS): + chat_history_enabled = options.get(CONF_CHAT_HISTORY, DEFAULT_CHAT_HISTORY) + if chat_history_enabled: + messages = _chatlog_to_langchain(chat_log) + else: + messages = [ + SystemMessage(content=chat_log.content[0].content), + HumanMessage(content=user_input.text), + ] + + try: + async for _content in chat_log.async_add_delta_content_stream( + user_input.agent_id, + _async_langchain_stream(bound_client, messages), + ): + pass + except Exception as err: + LOGGER.exception("Unexpected exception %s", type(err)) + response = intent.IntentResponse(language=user_input.language) + response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Houston we have a problem: {err}", + ) + return ConversationResult( + conversation_id=chat_log.conversation_id, response=response + ) + + if not chat_log.unresponded_tool_results: + break + + return conversation.async_get_result_from_chat_log(user_input, chat_log) + + +def _chatlog_to_langchain(chat_log: ChatLog) -> list[BaseMessage]: + """Convert ChatLog content to LangChain message list.""" + messages: list[BaseMessage] = [] + for content in chat_log.content: + if isinstance(content, SystemContent): + messages.append(SystemMessage(content=content.content)) + elif isinstance(content, UserContent): + messages.append(HumanMessage(content=content.content)) + elif isinstance(content, AssistantContent): + if content.tool_calls: + tool_calls = [ + { + "id": tc.id, + "name": tc.tool_name, + "args": tc.tool_args, + } + for tc in content.tool_calls + ] + messages.append( + AIMessage( + content=content.content or "", + tool_calls=tool_calls, + ) + ) + elif content.content: + messages.append(AIMessage(content=content.content)) + elif isinstance(content, ToolResultContent): + messages.append( + ToolMessage( + content=json.dumps(content.tool_result), + tool_call_id=content.tool_call_id, + name=content.tool_name, + ) + ) + return messages + + +async def _async_langchain_stream( + client: Any, messages: list[BaseMessage] +) -> AsyncIterable[dict[str, Any]]: + """Convert LangChain astream chunks to HA delta dicts.""" + first = True + async for chunk in client.astream(messages): + delta: dict[str, Any] = {} + if first: + delta["role"] = "assistant" + first = False + if chunk.content: + delta["content"] = chunk.content + + if chunk.tool_calls: + delta["tool_calls"] = [ + llm.ToolInput( + tool_name=tc["name"], + tool_args=tc["args"], + id=tc["id"], + ) + for tc in chunk.tool_calls + ] + + if delta: + yield delta diff --git a/custom_components/smartchain/manifest.json b/custom_components/smartchain/manifest.json new file mode 100644 index 0000000..ce4f43c --- /dev/null +++ b/custom_components/smartchain/manifest.json @@ -0,0 +1,20 @@ +{ + "domain": "smartchain", + "name": "SmartChain", + "codeowners": ["@dzerik"], + "config_flow": true, + "dependencies": ["ai_task", "conversation"], + "documentation": "https://github.com/dzerik/ha-smartchain", + "homekit": {}, + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/dzerik/ha-smartchain/issues", + "requirements": [ + "home-assistant-intents", + "langchain-gigachat>=0.3.0", + "langchain-openai>=0.3.0", + "langchain-community>=0.4.0", + "yandexcloud==0.295.0" + ], + "version": "0.7.0" +} diff --git a/custom_components/smartchain/strings.json b/custom_components/smartchain/strings.json new file mode 100644 index 0000000..1fe8687 --- /dev/null +++ b/custom_components/smartchain/strings.json @@ -0,0 +1,64 @@ +{ + "config": { + "step": { + "user": { + "title": "SmartChain configuration - select engine", + "data": { + "engine": "LLM Engine" + } + }, + "gigachat": { + "title": "GigaChat configuration", + "data": { + "api_key": "Auth data", + "skip_validation": "Skip validation" + } + }, + "yandexgpt": { + "title": "YandexGPT configuration", + "data": { + "api_key": "API Key", + "folder_id": "Folder ID", + "skip_validation": "Skip validation" + } + }, + "openai": { + "title": "OpenAI configuration", + "data": { + "api_key": "API Key", + "skip_validation": "Skip validation" + } + } + }, + "abort": { + "already_configured": "Already configured" + }, + "error": { + "cannot_connect": "Can not connect", + "invalid_response": "Invalid response", + "unknown": "Unknown error" + } + }, + "options": { + "error": { + "model_required": "Either Model or Custom Model required" + }, + "step": { + "init": { + "title": "Model configuration", + "data": { + "llm_hass_api": "Assist API (Control HA devices)", + "prompt": "Prompt Template", + "model": "Completion Model", + "model_user": "Custom Model Name (leave empty to use from list above)", + "temperature": "Temperature", + "max_tokens": "Max Tokens", + "profanity": "Profanity", + "verify_ssl": "Verify SSL Certificates", + "process_builtin_sentences": "Process HA Builtin Sentences", + "chat_history": "Chat History" + } + } + } + } +} diff --git a/custom_components/smartchain/translations/en.json b/custom_components/smartchain/translations/en.json new file mode 100644 index 0000000..1fe8687 --- /dev/null +++ b/custom_components/smartchain/translations/en.json @@ -0,0 +1,64 @@ +{ + "config": { + "step": { + "user": { + "title": "SmartChain configuration - select engine", + "data": { + "engine": "LLM Engine" + } + }, + "gigachat": { + "title": "GigaChat configuration", + "data": { + "api_key": "Auth data", + "skip_validation": "Skip validation" + } + }, + "yandexgpt": { + "title": "YandexGPT configuration", + "data": { + "api_key": "API Key", + "folder_id": "Folder ID", + "skip_validation": "Skip validation" + } + }, + "openai": { + "title": "OpenAI configuration", + "data": { + "api_key": "API Key", + "skip_validation": "Skip validation" + } + } + }, + "abort": { + "already_configured": "Already configured" + }, + "error": { + "cannot_connect": "Can not connect", + "invalid_response": "Invalid response", + "unknown": "Unknown error" + } + }, + "options": { + "error": { + "model_required": "Either Model or Custom Model required" + }, + "step": { + "init": { + "title": "Model configuration", + "data": { + "llm_hass_api": "Assist API (Control HA devices)", + "prompt": "Prompt Template", + "model": "Completion Model", + "model_user": "Custom Model Name (leave empty to use from list above)", + "temperature": "Temperature", + "max_tokens": "Max Tokens", + "profanity": "Profanity", + "verify_ssl": "Verify SSL Certificates", + "process_builtin_sentences": "Process HA Builtin Sentences", + "chat_history": "Chat History" + } + } + } + } +} diff --git a/custom_components/smartchain/translations/ru.json b/custom_components/smartchain/translations/ru.json new file mode 100644 index 0000000..5ceae8d --- /dev/null +++ b/custom_components/smartchain/translations/ru.json @@ -0,0 +1,64 @@ +{ + "config": { + "step": { + "user": { + "title": "SmartChain - выбор LLM провайдера", + "data": { + "engine": "LLM провайдер" + } + }, + "gigachat": { + "title": "GigaChat", + "data": { + "api_key": "Авторизационные данные", + "skip_validation": "Пропустить проверку" + } + }, + "yandexgpt": { + "title": "YandexGPT", + "data": { + "api_key": "API ключ", + "folder_id": "Идентификатор каталога (Folder ID)", + "skip_validation": "Пропустить проверку" + } + }, + "openai": { + "title": "OpenAI", + "data": { + "api_key": "API ключ", + "skip_validation": "Пропустить проверку" + } + } + }, + "abort": { + "already_configured": "Этот провайдер уже настроен" + }, + "error": { + "cannot_connect": "Не удаётся подключиться", + "invalid_response": "Некорректный ответ", + "unknown": "Неизвестная ошибка" + } + }, + "options": { + "error": { + "model_required": "Выберите модель из списка либо задайте свою" + }, + "step": { + "init": { + "title": "Настройки модели", + "data": { + "llm_hass_api": "Assist API (управление устройствами HA)", + "prompt": "Системный промпт", + "model": "Модель", + "model_user": "Своё имя модели (оставьте пустым для использования из списка)", + "temperature": "Температура", + "max_tokens": "Максимум токенов", + "profanity": "Цензура", + "verify_ssl": "Проверка SSL сертификатов", + "process_builtin_sentences": "Встроенный командный процессор HA", + "chat_history": "История сообщений" + } + } + } + } +} diff --git a/docs/COMPETITIVE_ANALYSIS.md b/docs/COMPETITIVE_ANALYSIS.md new file mode 100644 index 0000000..5726c6a --- /dev/null +++ b/docs/COMPETITIVE_ANALYSIS.md @@ -0,0 +1,382 @@ +# SmartChain — Конкурентный анализ и точки роста + +Дата: 2026-03-10 | Версия: 0.5.0 + +## Оглавление + +1. [Конкуренты — интеграции HA для LLM](#1-конкуренты--интеграции-ha-для-llm) +2. [Что есть у конкурентов, чего нет у нас](#2-что-есть-у-конкурентов-чего-нет-у-нас) +3. [Российские и китайские модели](#3-российские-и-китайские-модели--кого-можно-подключить) +4. [Легковесные локальные модели](#4-легковесные-локальные-модели) +5. [Точки роста и пути развития](#5-точки-роста-и-пути-развития) +6. [Стратегическое позиционирование](#6-стратегическое-позиционирование) +7. [Источники](#7-источники) + +--- + +## 1. Конкуренты — интеграции HA для LLM + +### Официальные интеграции (встроены в HA core) + +| Интеграция | Провайдер | Device Control | AI Task | Streaming | Vision | MCP | +|---|---|---|---|---|---|---| +| OpenAI Conversation | OpenAI | Assist API | да | да | да | да | +| Anthropic | Claude | Assist API | да | да | нет | да | +| Google Gemini | Gemini | Assist API | да | да | да | да | +| Ollama | Локальные | Assist API | да | да | нет | да | +| OpenRouter | 400+ моделей | Assist API | да | да | нет | да | + +Все официальные интеграции поддерживают: +- Assist API для управления устройствами +- AI Task entity для генерации данных в автоматизациях +- Sub-entries (несколько агентов с разными моделями через одну интеграцию) +- MCP (Model Context Protocol) для расширения возможностей внешними инструментами +- Streaming ответов +- Conversational follow-ups (LLM может задавать уточняющие вопросы) +- Per-device LLM assignment (разный LLM для разных устройств) + +### Custom-интеграции (HACS) + +#### Extended OpenAI Conversation (~1.5k stars) +- GitHub: https://github.com/jekalmin/extended_openai_conversation +- **Function calling** — вызов сервисов HA через OpenAI API +- **Создание автоматизаций** через естественный язык +- **Чтение истории состояний** — LLM знает, что было раньше +- **REST API** — запросы к внешним API +- **Веб-скрейпинг** — получение данных с веб-страниц +- **Skill-система** — загружаемые навыки из директории +- **Multi-agent** — Dispatcher Agent маршрутизирует запросы между специализированными агентами +- **Attach Username** — персонализация по имени пользователя +- Поддержка native functions: `execute_service`, `add_automation`, `get_history` + +#### YandexGPT (black-roland, 38 stars) +- GitHub: https://github.com/black-roland/homeassistant-yandexgpt +- **Управление устройствами** — включение/выключение света, регулировка температуры, запуск скриптов +- **YandexART** — генерация изображений +- **Telegram-бот** — использование как backend для Telegram +- **Yandex SpeechKit** — companion-интеграция для STT/TTS +- Последний релиз: v1.5.5 (декабрь 2025) +- Лицензия: MPL-2.0 + +#### Cloud.ru Foundation Models (black-roland, новый) +- GitHub: https://github.com/black-roland/homeassistant-cloud-ru-ai +- **Открытые модели**: GPT-OSS-120b, Qwen3, Llama, DeepSeek R1 Distill, GLM-4.5, T-Pro +- **Управление устройствами** — полный контроль через чат +- Основан на официальной интеграции OpenAI HA +- Лицензия: Apache 2.0 + +#### Home-LLM (acon96, ~3k stars) +- GitHub: https://github.com/acon96/home-llm +- **Полностью локальный** — никаких облачных сервисов +- **Fine-tuned модели** — Home-3B-v3 (97% точность function calling), Home-1B-v3 +- **Tool calling** — переписан для поддержки agentic tool use loop +- **AI Task entity** — генерация данных для автоматизаций +- **Multi-language** — EN, DE, FR, ES, PL +- **CPU-friendly** — работает на Raspberry Pi +- Backends: Ollama, llama.cpp, OpenAI-compatible API + +#### LLM Vision (~1.5k stars) +- GitHub: https://github.com/valentinfrlch/ha-llmvision +- **Мультимодальный** — анализ изображений, видео, live камер, Frigate events +- **Timeline** — хранит историю событий камер +- **Распознавание** — люди, номерные знаки, объекты +- **Голосовые запросы** — "Была ли активность во дворе вчера?" +- Провайдеры: OpenAI, Anthropic, Gemini, Ollama, OpenRouter, и др. + +#### SmartChain (наш, 15 stars) +- GigaChat + YandexGPT + OpenAI через LangChain +- Streaming ответов +- ChatLog для истории +- Builtin sentence processor +- Системный промпт с Jinja2 + +--- + +## 2. Что есть у конкурентов, чего нет у нас + +### Критические отставания + +| Фича | У кого есть | Описание | +|---|---|---| +| Управление устройствами (Assist API) | Все official, Extended OpenAI, YandexGPT, Cloud.ru | LLM может включать свет, менять температуру, запускать сценарии | +| AI Task entity | Все official, Home-LLM | Генерация данных для автоматизаций через `ai_task.generate_data` | +| MCP (Model Context Protocol) | Все official | Расширение возможностей агента внешними инструментами | +| Function calling / вызов сервисов | Extended OpenAI, все official | LLM вызывает произвольные сервисы HA | +| Vision / мультимодальность | OpenAI, Gemini, LLM Vision | Анализ камер и изображений | +| Генерация изображений | YandexGPT (YandexART) | Создание изображений по описанию | +| Создание автоматизаций через AI | Extended OpenAI | Генерация автоматизаций HA из естественного языка | +| Доступ к истории состояний | Extended OpenAI | LLM анализирует тренды и прошлые события | + +### Менее критичные + +| Фича | У кого есть | +|---|---| +| Multi-agent (Dispatcher + специализированные) | Extended OpenAI | +| Skill-система (загружаемые навыки) | Extended OpenAI | +| Telegram-бот | YandexGPT | +| STT/TTS от того же провайдера | YandexGPT + SpeechKit | +| Prompt caching | llama.cpp custom-conversation | +| Sub-entries (несколько агентов) | Все official (HA 2025.8+) | +| Conversational follow-ups | Все official | +| Per-device LLM assignment | Все official | + +--- + +## 3. Российские и китайские модели — кого можно подключить + +### Российские модели с API + +#### GigaChat 2.0 (Сбер) — уже подключён +- Линейка: Lite / Pro / MAX +- Контекст: до 128K токенов +- Мультимодальность: да (GigaChat 2.0) +- Генерация изображений: да (Kandinsky) +- Цена: ~650 руб/1M токенов (MAX), 1M бесплатных токенов/месяц для разработчиков +- LangChain: `langchain-gigachat` (подключён) +- Function calling: поддерживается + +#### YandexGPT 4 / Alice AI LLM (Яндекс) — уже подключён +- Линейка: Lite / Pro +- Контекст: до 32K токенов (до 60 страниц) +- Цена: ~1220 руб/1M токенов (Pro) +- LangChain: `langchain_community.chat_models.ChatYandexGPT` (подключён) +- Доп. сервисы: YandexART (генерация изображений), SpeechKit (STT/TTS) +- Примечание: в октябре 2025 переименован в Alice AI LLM + +#### T-Pro 2.0 (Т-Банк / Т-Технологии) — НЕ подключён +- Параметры: 32B +- Лицензия: Apache 2.0 (полностью открытая) +- Гибридный reasoning (быстрые ответы + многошаговое рассуждение) +- На 30% экономичнее Qwen3 и DeepSeek R1-Distil на русском +- Лидер бенчмарков MERA, ruMMLU, ruArena Hard для русского +- Основана на Qwen3 32B с улучшенным кириллическим токенизатором +- Доступ: HuggingFace, self-hosted через Ollama/vLLM +- **Рекомендация: подключить через Ollama backend** + +#### T-Lite (Т-Банк) — НЕ подключён +- Параметры: 7B +- Лицензия: Apache 2.0 +- Компактная модель для устройств со средней производительностью +- Доступ: HuggingFace, Ollama +- **Рекомендация: подключить через Ollama как легковесную опцию** + +#### Cotype Pro 2.5 (MTS AI / MWS AI) — НЕ подключён +- Лидер среди российских LLM в бенчмарке MERA +- Агентные навыки: в 10 раз эффективнее Cotype Pro 2 +- Превосходит Qwen3-32B на 22% по точности на русском +- Доступ: enterprise API (MWS Cloud) +- **Рекомендация: рассмотреть при наличии API** + +#### Cotype Nano (MTS AI) — НЕ подключён +- Открытая модель, работает на мобильных устройствах и ноутбуках +- Лучшие результаты в своём классе на Ru Arena Hard +- **Рекомендация: подключить через Ollama** + +### Китайские модели (доступны из России) + +#### DeepSeek V3 / R1 — НЕ подключён +- V3: general-purpose, tool calling, structured output +- R1: reasoning, chain-of-thought +- LangChain: `langchain-deepseek` (`ChatDeepSeek`) +- Цена: самая низкая на рынке (~$0.14/1M input tokens) +- Доступ из РФ: API (возможны проблемы с блокировкой IP Роскомнадзором), self-hosted через Ollama +- **Рекомендация: подключить как самый дешёвый cloud-провайдер** + +#### Qwen 3 (Alibaba) — НЕ подключён +- Модели от 0.6B до 235B параметров +- Самая скачиваемая серия моделей на HuggingFace (2025-2026) +- Reasoning mode, tool calling +- LangChain: `ChatOllama` или Cloud.ru +- **Рекомендация: подключить через Ollama backend** + +#### GLM-4.5 (Zhipu AI) — НЕ подключён +- Доступ: Cloud.ru Foundation Models +- **Рекомендация: подключить через Cloud.ru или Ollama** + +#### Baichuan, Yi, MiniCPM — НЕ подключены +- Open-source, self-hosted через Ollama +- **Рекомендация: автоматически доступны при поддержке Ollama** + +--- + +## 4. Легковесные локальные модели + +Для пользователей без мощного GPU или с Raspberry Pi: + +| Модель | Параметры | RAM | Язык | Особенности | +|---|---|---|---|---| +| Home-3B-v3 | 3B | ~2GB | EN/DE/FR/ES | 97% точность function calling HA | +| Home-1B-v3 | 1B | ~1GB | EN | Raspberry Pi compatible | +| T-Lite | 7B | ~4GB | RU | лучшая для русского в классе 7B | +| T-Pro 2.0 | 32B | ~18GB | RU | лидер русскоязычных бенчмарков | +| Qwen3 | 0.6B-4B | 0.5-3GB | RU/EN/ZH | reasoning, tool calling | +| Phi-4-mini | 3.8B | ~2GB | EN | Microsoft, компактная | +| Gemma 3 | 1B-4B | 1-3GB | EN | Google, vision support | +| DeepSeek R1 Distill | 1.5B-14B | 1-8GB | EN/ZH | reasoning | +| Cotype Nano | small | ~2GB | RU | MTS AI, открытая | +| MiniCPM | 1.2B-8B | 1-4GB | ZH/EN | OpenBMB, компактная | + +Все локальные модели доступны через **Ollama** — единый backend для self-hosted LLM. + +--- + +## 5. Точки роста и пути развития + +### Приоритет: Высокий (конкурентный паритет) + +#### 5.1 Управление устройствами через Assist API +- **Что:** Использовать `chat_log.async_provide_llm_data()` для доступа к HA tools +- **Зачем:** Главная фича, которую имеют ВСЕ конкуренты. Без неё SmartChain — только чат-бот, не smart home agent +- **Как:** HA LLM API предоставляет intents для управления exposed entities. Нужно передать tools в LLM и обработать tool calls +- **Сложность:** Средняя. GigaChat и DeepSeek поддерживают function calling. Паттерн задокументирован в HA developer docs + +#### 5.2 AI Task entity +- **Что:** Добавить `AITaskEntity` с методом `_async_generate_data()` +- **Зачем:** Позволит использовать SmartChain в автоматизациях, скриптах, шаблонах через `ai_task.generate_data` +- **Как:** Новый entity наряду с ConversationEntity, может разделять общую логику обработки chat_log +- **Сложность:** Низкая-средняя + +#### 5.3 Новые провайдеры LLM +- **DeepSeek** (`pip install langchain-deepseek`) — `ChatDeepSeek`, самый дешёвый, tool calling +- **Ollama** (`pip install langchain-ollama`) — `ChatOllama`, все локальные модели (T-Pro, Qwen, Llama, DeepSeek) +- **Anthropic** (`pip install langchain-anthropic`) — `ChatAnthropic`, extended thinking +- **Зачем:** Расширение аудитории, поддержка локальных моделей, снижение стоимости +- **Сложность:** Низкая — архитектура через LangChain уже позволяет добавлять провайдеров в `client_util.py` + +### Приоритет: Средний (дифференциация) + +#### 5.4 Function calling / вызов сервисов HA +- **Что:** LLM вызывает произвольные сервисы HA (свет, климат, сценарии) +- **Зачем:** Extended OpenAI Conversation — самая популярная custom-интеграция именно из-за этой фичи +- **Как:** Определить tool specs для `execute_service`, `get_history`, передавать в LLM, обрабатывать tool calls в цикле +- **Сложность:** Средняя-высокая + +#### 5.5 Vision / мультимодальность +- **Что:** Анализ изображений с камер через LLM +- **Зачем:** GigaChat 2.0 поддерживает мультимодальность. Можно анализировать камеры без LLM Vision +- **Как:** Передавать изображения как attachments в LLM запрос +- **Сложность:** Средняя + +#### 5.6 Генерация изображений +- **Что:** GigaChat (Kandinsky) и YandexART для создания изображений +- **Зачем:** Уникальная фича для российского рынка. У конкурента YandexGPT уже есть +- **Как:** Отдельный сервис или entity для генерации +- **Сложность:** Средняя + +#### 5.7 Sub-entries (несколько агентов) +- **Что:** Позволить создавать несколько conversation agents с разными моделями/промптами через одну интеграцию +- **Зачем:** Паттерн из HA 2025.8+. Один агент для чата, другой для device control, третий для генерации +- **Сложность:** Средняя + +#### 5.8 MCP (Model Context Protocol) +- **Что:** Интеграция с MCP серверами для расширения возможностей агента +- **Зачем:** Все official-интеграции поддерживают. Даёт доступ к новостям, todo-спискам, внешним данным +- **Сложность:** Средняя-высокая + +### Приоритет: Низкий (nice to have) + +#### 5.9 Доступ к истории состояний +- LLM анализирует тренды и прошлые события ("Какая была температура вчера?") + +#### 5.10 Telegram-бот +- Использовать SmartChain как backend для Telegram (как у YandexGPT) + +#### 5.11 STT/TTS интеграция +- Связка с Yandex SpeechKit или GigaChat TTS для полного voice pipeline на русском + +#### 5.12 Prompt caching +- Кэширование промптов для ускорения повторных запросов (как в llama.cpp) + +#### 5.13 Multi-agent +- Dispatcher agent маршрутизирует запросы между специализированными агентами + +#### 5.14 Skill-система +- Загружаемые "навыки" для разных доменов (погода, расписание, рецепты) + +#### 5.15 Conversational follow-ups +- LLM задаёт уточняющие вопросы, HA слушает ответ + +--- + +## 6. Стратегическое позиционирование + +### Текущее УТП (Unique Selling Proposition) + +SmartChain — **единственная** HA интеграция, объединяющая GigaChat + YandexGPT + OpenAI в одном компоненте через LangChain. Это позволяет: +- Переключаться между провайдерами без переустановки +- Использовать единый интерфейс для разных LLM +- Легко добавлять новые модели через LangChain экосистему + +### Слабые стороны + +- Нет управления устройствами — главный разрыв с конкурентами +- Нет AI Task — не может использоваться в автоматизациях +- Нет локальных моделей (Ollama) — зависимость от cloud API +- Небольшое сообщество (15 stars vs 3k у Home-LLM) + +### Рекомендуемая дорожная карта + +```mermaid +gantt + title SmartChain Roadmap + dateFormat YYYY-MM + section Высокий приоритет + Assist API + Device Control :2026-03, 2026-04 + AI Task entity :2026-04, 2026-05 + DeepSeek + Ollama провайдеры :2026-04, 2026-05 + section Средний приоритет + Function calling :2026-05, 2026-06 + Vision / мультимодальность :2026-06, 2026-07 + Sub-entries :2026-06, 2026-07 + Генерация изображений :2026-07, 2026-08 + section Низкий приоритет + Multi-agent :2026-08, 2026-09 + Telegram-бот :2026-09, 2026-10 +``` + +### Целевая аудитория + +1. **Российские пользователи HA** — GigaChat + YandexGPT без VPN +2. **Privacy-conscious** — Ollama + T-Pro/Qwen для полностью локального решения +3. **Мультипровайдерные** — один компонент вместо нескольких интеграций +4. **Разработчики** — LangChain экосистема для кастомизации + +--- + +## 7. Источники + +### Официальная документация HA +- https://www.home-assistant.io/blog/2025/09/11/ai-in-home-assistant/ +- https://developers.home-assistant.io/docs/core/llm/ +- https://www.home-assistant.io/integrations/openai_conversation/ +- https://www.home-assistant.io/integrations/anthropic/ +- https://www.home-assistant.io/integrations/google_generative_ai_conversation/ +- https://www.home-assistant.io/integrations/ollama/ +- https://www.home-assistant.io/integrations/open_router/ +- https://www.home-assistant.io/integrations/ai_task/ +- https://www.home-assistant.io/blog/2025/08/06/release-20258/ +- https://developers.home-assistant.io/docs/core/entity/ai-task/ +- https://developers.home-assistant.io/docs/core/entity/conversation + +### Custom-интеграции +- https://github.com/jekalmin/extended_openai_conversation +- https://github.com/black-roland/homeassistant-yandexgpt +- https://github.com/black-roland/homeassistant-cloud-ru-ai +- https://github.com/acon96/home-llm +- https://github.com/valentinfrlch/ha-llmvision + +### Российские LLM +- https://vc.ru/ai/2733649-rossiyskie-neuroseti-2026-modeli-i-servisy-dlya-biznesa +- https://habr.com/ru/companies/tbank/articles/928956/ +- https://mts.ai/tech/mts-ai-releases-cotype-pro-2-second-generation-business-focused-llm/ +- https://www.tbank.ru/about/news/11122024-the-t-technologies-group-has-introduced-the-worlds-most-efficient-open-large-language-models-in-russian/ + +### Китайские LLM +- https://intuitionlabs.ai/articles/chinese-open-source-llms-2025 +- https://changelog.langchain.com/announcements/deepseek-integration-in-langchain +- https://www.index.dev/blog/chinese-ai-models-deepseek + +### LangChain интеграции +- https://docs.langchain.com/oss/python/integrations/providers/yandex +- https://docs.langchain.com/oss/python/integrations/chat/deepseek +- https://ollama.com/fixt/home-3b-v3 diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md new file mode 100644 index 0000000..62079b3 --- /dev/null +++ b/docs/DOCUMENTATION.md @@ -0,0 +1,203 @@ +# SmartChain — Technical Documentation + +## Overview + +**SmartChain** is a custom component for [Home Assistant](https://www.home-assistant.io/) providing a voice/conversation assistant using multiple LLM providers via LangChain. + +- **Version:** 0.7.0 +- **Domain:** `smartchain` +- **Integration type:** service +- **IoT class:** cloud_polling +- **Distribution:** [HACS](https://hacs.xyz/) + +## Architecture + +```mermaid +flowchart TD + A[User] -->|Voice / Text| B[Home Assistant Voice Pipeline] + B --> C{Builtin Sentence Processor} + C -->|Recognized| D[HA Intent Handler] + C -->|Not recognized| E[SmartChainConversationEntity] + E --> F{LLM Engine} + F --> G[GigaChat API] + F --> H[YandexGPT API] + F --> I[OpenAI API] + G --> J[Response to user] + H --> J + I --> J + D --> J + + K[HA Automation] -->|ai_task.generate_data| L[SmartChainAITaskEntity] + L --> F +``` + +### Key Components + +```mermaid +classDiagram + class SmartChainConversationEntity { + +entry: ConfigEntry + +supported_languages: list + +_async_handle_message(user_input, chat_log) ConversationResult + } + + class SmartChainAITaskEntity { + +entry: ConfigEntry + +_async_generate_data(task, chat_log) GenDataTaskResult + } + + class ConfigFlow { + +async_step_user(user_input) ConfigFlowResult + +async_step_gigachat(user_input) ConfigFlowResult + +async_step_yandexgpt(user_input) ConfigFlowResult + +async_step_openai(user_input) ConfigFlowResult + } + + class client_util { + +validate_client(hass, user_input) + +get_client(hass, engine, entry, common_args) + } + + SmartChainConversationEntity --|> ConversationEntity + SmartChainAITaskEntity --|> AITaskEntity + SmartChainConversationEntity --> client_util + SmartChainAITaskEntity --> client_util +``` + +## File Structure + +``` +ha-smartchain/ +├── custom_components/ +│ └── smartchain/ +│ ├── __init__.py # Entry setup/unload, platform registration +│ ├── conversation.py # ConversationEntity (streaming, tool calling) +│ ├── ai_task.py # AITaskEntity (data generation) +│ ├── config_flow.py # Config Flow + Options Flow +│ ├── client_util.py # LLM client factory + validation +│ ├── const.py # Constants, prompts, model lists +│ ├── manifest.json # Integration metadata +│ ├── strings.json # Base localization strings +│ └── translations/ +│ ├── en.json # English localization +│ └── ru.json # Russian localization +├── tests/ +│ ├── conftest.py # Fixtures (hass, mock LLM client) +│ ├── test_config_flow.py # Config Flow tests (11) +│ ├── test_init.py # Conversation entity tests (19) +│ ├── test_ai_task.py # AI Task entity tests (7) +│ └── test_setup.py # Setup/unload tests (4) +├── docs/ +│ ├── DOCUMENTATION.md # This file +│ ├── COMPETITIVE_ANALYSIS.md # Competitive analysis +│ └── ROADMAP.md # Development roadmap +├── CLAUDE.md # Project rules for Claude Code +├── CHANGELOG.md # Version changelog +├── TODO.md # Task checklist +├── README.md / README-ru.md # User documentation (EN/RU) +├── pytest.ini # Pytest configuration +├── requirements_test.txt # Test dependencies +├── hacs.json # HACS metadata +└── LICENSE # MIT license +``` + +## Supported LLM Providers + +| Provider | ID | Client Class | Auth Parameters | +| ------------- | ----------- | -------------------------------------- | ------------------------ | +| **GigaChat** | `gigachat` | `GigaChat` (langchain-gigachat) | `credentials` | +| **YandexGPT** | `yandexgpt` | `ChatYandexGPT` (langchain-community) | `api_key` + `folder_id` | +| **OpenAI** | `openai` | `ChatOpenAI` (langchain-openai) | `openai_api_key` | + +### Available Models + +- **GigaChat:** GigaChat, GigaChat:latest, GigaChat-Plus, GigaChat-Pro, GigaChat-Max +- **YandexGPT:** YandexGPT, YandexGPT Lite, Summary +- **OpenAI:** gpt-4.1, gpt-4.1-mini, gpt-4.1-nano, gpt-4o, gpt-4o-mini, o3, o3-mini, o4-mini + +Custom model names are also supported. + +## Conversation Flow + +```mermaid +flowchart TD + A[Message + ChatLog] --> B{Assist API configured?} + B -->|Yes| C[async_provide_llm_data - tools + prompt] + B -->|No| D[Manual Jinja2 prompt + device list] + C --> E{Builtin sentences?} + D --> F{Builtin sentences enabled?} + F -->|Yes| G[Try HA Default Agent] + F -->|No| H[Build LangChain messages] + E --> H + G --> I{Recognized?} + I -->|Yes| J[Return HA response] + I -->|No| H + H --> K[client.astream - streaming] + K --> L{Tool calls?} + L -->|Yes| M[Execute tools via HA] + M --> H + L -->|No| N[Return response] +``` + +### Streaming +Responses stream token-by-token via `ChatLog.async_add_delta_content_stream()`. The `_async_langchain_stream()` generator converts LangChain `AIMessageChunk` to HA delta dicts. + +### Tool Calling (Assist API) +When `llm_hass_api` is configured, HA tools (lights, switches, etc.) are converted to LangChain format via `_ha_tool_to_dict()` and bound to the client with `bind_tools()`. The tool calling loop runs up to `MAX_TOOL_ITERATIONS = 10` times. + +### AI Task Entity +`SmartChainAITaskEntity` implements `ai_task.AITaskEntity` for use in automations via `ai_task.generate_data`. Supports: +- Plain text generation +- Structured output (JSON parsing with `task.structure`) +- Tool calling (same as conversation entity) + +## Configuration Parameters + +### Data (set during installation) + +| Parameter | Key | Type | Description | +| --------- | ----------- | ----- | ---------------------------------------- | +| Engine | `engine` | `str` | LLM engine ID | +| API Key | `api_key` | `str` | Authentication key | +| Folder ID | `folder_id` | `str` | Yandex Cloud folder (YandexGPT only) | + +### Options (configurable after installation) + +| Parameter | Key | Type | Default | Description | +| ---------------------- | -------------------------- | ---------- | -------- | ------------------------------------ | +| Model (list) | `model` | `str` | `""` | Model from provider list | +| Model (custom) | `model_user` | `str` | `""` | Custom model name | +| Assist API | `llm_hass_api` | `list` | - | HA LLM API for device control | +| Prompt | `prompt` | `template` | Default | System prompt (Jinja2) | +| Temperature | `temperature` | `float` | `0.1` | Generation temperature | +| Max Tokens | `max_tokens` | `int` | - | Max response tokens | +| Builtin Sentences | `process_builtin_sentences`| `bool` | `True` | Try HA builtin handler first | +| Chat History | `chat_history` | `bool` | `True` | Keep conversation history | +| Profanity | `profanity` | `bool` | `False` | Profanity filter (GigaChat only) | +| Verify SSL | `verify_ssl` | `bool` | `False` | SSL cert verification (GigaChat) | + +## Testing + +```bash +pip install pytest-homeassistant-custom-component +python3 -m pytest tests/ -v +``` + +### Test Coverage (41 tests) + +- **test_config_flow.py** — 11 tests (engine selection, full flows, error handling, skip validation) +- **test_init.py** — 19 tests (conversation entity, streaming, tool calling, history, prompts) +- **test_ai_task.py** — 7 tests (data generation, structured output, errors, tools) +- **test_setup.py** — 4 tests (setup, unload, entity creation) + +## Dependencies + +| Package | Description | +| -------------------------- | ------------------------------ | +| `home-assistant-intents` | Language support | +| `langchain-gigachat>=0.3.0`| GigaChat LLM client | +| `langchain-openai>=0.3.0` | OpenAI LLM client | +| `langchain-community>=0.4.0`| YandexGPT + LangChain utils | +| `yandexcloud==0.295.0` | Yandex Cloud SDK | + +HA dependencies: `ai_task`, `conversation` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..9d9a724 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,371 @@ +# SmartChain — Дорожная карта развития + +Дата: 2026-03-10 | Текущая версия: 0.6.0 + +## Оглавление + +1. [Текущее состояние](#1-текущее-состояние) +2. [Фаза 1 — Конкурентный паритет](#2-фаза-1--конкурентный-паритет-v07--v09) +3. [Фаза 2 — Дифференциация](#3-фаза-2--дифференциация-v10--v13) +4. [Фаза 3 — Лидерство](#4-фаза-3--лидерство-v14) +5. [Технические детали по задачам](#5-технические-детали-по-задачам) + +--- + +## 1. Текущее состояние + +### Реализовано (v0.1–v0.6) + +| Версия | Что сделано | +|--------|-------------| +| 0.1.x | Базовая интеграция: GigaChat + YandexGPT + OpenAI, Config/Options Flow, история диалогов, Jinja2 промпт | +| 0.2.0 | Исправление блокировки event loop, deprecated API, утечки памяти, удаление Anyscale | +| 0.2.1 | verify_ssl для GigaChat, обновление CI (Python 3.12, ruff) | +| 0.3.0 | Миграция на `ConversationEntity` API, `conversation.py`, pytest (20 тестов) | +| 0.4.0 | Миграция на ChatLog, langchain-gigachat/langchain-openai, CI pytest (26 тестов) | +| 0.5.0 | Streaming ответов через `astream()` + `async_add_delta_content_stream()` (29 тестов) | +| 0.6.0 | **Assist API** — управление устройствами через tool calling, LLM API selector, обновление промпта и моделей (34 теста) | + +### Текущая архитектура + +```mermaid +graph TB + subgraph "SmartChain v0.6.0" + CF[Config Flow] --> CU[client_util.py] + CU --> GC[GigaChat] + CU --> YGP[YandexGPT] + CU --> OAI[OpenAI] + + CE[ConversationEntity] --> CL[ChatLog] + CE --> ST[Streaming] + CE --> AA[Assist API] + AA --> TC[Tool Calling Loop] + TC --> BT[bind_tools] + end + + subgraph "Home Assistant" + HA_LLM[HA LLM API] --> AA + HA_TOOLS[Intents & Services] --> HA_LLM + end +``` + +### Разрыв с конкурентами + +| Фича | Official HA | Extended OpenAI | YandexGPT | Home-LLM | SmartChain | +|-------|:-----------:|:---------------:|:---------:|:--------:|:---------:| +| Assist API / Device Control | + | + | + | + | **+ (v0.6)** | +| AI Task entity | + | - | - | + | - | +| Streaming | + | + | - | + | **+ (v0.5)** | +| MCP | + | - | - | - | - | +| Vision | + (OpenAI, Gemini) | - | - | - | - | +| Генерация изображений | - | - | + | - | - | +| Function calling (custom) | - | + | + | + | - | +| Sub-entries | + | - | - | - | - | +| Ollama / локальные модели | + | - | - | + | - | +| Multi-agent | - | + | - | - | - | +| Telegram-бот | - | - | + | - | - | +| История состояний | - | + | - | - | - | + +--- + +## 2. Фаза 1 — Конкурентный паритет (v0.7 – v0.9) + +Цель: закрыть критические разрывы с конкурентами, стать полноценным smart home agent. + +### v0.7 — AI Task entity + +**Приоритет:** Высокий +**Сложность:** Низкая-средняя +**Зависимости:** нет + +**Что:** +- Добавить `AITaskEntity` с методом `_async_generate_data(task, chat_log)` +- Позволяет использовать SmartChain в автоматизациях HA через `ai_task.generate_data` +- Примеры: "Составь план уборки на основе загрязнённости комнат", "Проанализируй расход электричества за неделю" + +**Файлы:** +- `custom_components/smartchain/ai_task.py` — новый файл с `SmartChainAITaskEntity` +- `custom_components/smartchain/__init__.py` — добавить `Platform.AI_TASK` +- `custom_components/smartchain/manifest.json` — добавить `"ai_task"` в dependencies +- `tests/test_ai_task.py` — тесты + +**Реализация:** +```python +class SmartChainAITaskEntity(AITaskEntity): + async def _async_generate_data( + self, task: ai_task.GenData, chat_log: ChatLog + ) -> None: + # Предоставить LLM данные (tools, prompt) + await chat_log.async_provide_llm_data(...) + # Вызвать LLM через общую логику streaming + tool calling + # Результат записывается в chat_log автоматически +``` + +**Референсы:** +- https://developers.home-assistant.io/docs/core/entity/ai-task/ +- `homeassistant.components.openai_conversation.ai_task` + +--- + +### v0.8 — Новые провайдеры LLM (Ollama, DeepSeek, Anthropic) + +**Приоритет:** Высокий +**Сложность:** Низкая +**Зависимости:** нет + +**Что:** +Добавить 3 новых провайдера через LangChain. Архитектура уже позволяет — нужно расширить `client_util.py` и Config Flow. + +#### 0.8.1 — Ollama (локальные модели) + +Открывает доступ ко всем локальным моделям: T-Pro 2.0, T-Lite, Qwen3, Llama, DeepSeek, Gemma, Phi, Home-3B. + +**Файлы:** +- `const.py` — `ID_OLLAMA`, `UNIQUE_ID_OLLAMA`, модели, `CONF_BASE_URL` +- `client_util.py` — `ChatOllama(model=..., base_url=...)` +- `config_flow.py` — шаг `async_step_ollama` (base_url + model) +- `manifest.json` — `langchain-ollama>=0.3.0` +- `strings.json`, `translations/` — строки для Ollama + +**Config:** +- `base_url` (по умолчанию `http://localhost:11434`) +- `model` (текстовое поле, т.к. модели загружаются пользователем) + +#### 0.8.2 — DeepSeek + +Самый дешёвый cloud-провайдер. V3 для обычных задач, R1 для reasoning. + +**Файлы:** +- `const.py` — `ID_DEEPSEEK`, модели (`deepseek-chat`, `deepseek-reasoner`) +- `client_util.py` — `ChatDeepSeek(model=..., api_key=...)` +- `config_flow.py` — шаг `async_step_deepseek` +- `manifest.json` — `langchain-deepseek>=0.1.0` + +#### 0.8.3 — Anthropic (Claude) + +Для пользователей, которые хотят Claude через SmartChain. + +**Файлы:** +- `const.py` — `ID_ANTHROPIC`, модели (`claude-sonnet-4-6`, `claude-haiku-4-5`) +- `client_util.py` — `ChatAnthropic(model=..., api_key=...)` +- `config_flow.py` — шаг `async_step_anthropic` +- `manifest.json` — `langchain-anthropic>=0.3.0` + +--- + +### v0.9 — Sub-entries (несколько агентов) + +**Приоритет:** Средний-высокий +**Сложность:** Средняя +**Зависимости:** v0.8 (больше пользы с несколькими провайдерами) + +**Что:** +- Позволить создавать несколько conversation/AI task agents с разными моделями и промптами через одну интеграцию +- Паттерн из HA 2025.8+ — все официальные интеграции перешли на sub-entries +- Пример: "GigaChat Max для управления домом" + "GigaChat Lite для чата" + "DeepSeek R1 для аналитики" + +**Файлы:** +- `config_flow.py` — переработка: корневая entry хранит credentials, sub-entries хранят модель/промпт/API +- `__init__.py` — setup sub-entries +- `conversation.py`, `ai_task.py` — привязка к sub-entry + +**Референсы:** +- https://developers.home-assistant.io/docs/config_entries/subentries +- `homeassistant.components.openai_conversation` (реализация через sub-entries) + +--- + +## 3. Фаза 2 — Дифференциация (v1.0 – v1.3) + +Цель: добавить уникальные возможности, которых нет у большинства конкурентов. + +### v1.0 — Vision / мультимодальность + +**Приоритет:** Средний +**Сложность:** Средняя +**Зависимости:** v0.8 (нужны провайдеры с vision: GigaChat 2.0, OpenAI, Ollama) + +**Что:** +- Отправка изображений с камер HA в LLM +- Анализ: "Кто у двери?", "Что показывает камера во дворе?" +- Поддержка: GigaChat 2.0 (мультимодальный), OpenAI GPT-4o/4.1, Ollama (LLaVA, Gemma Vision) + +**Реализация:** +- Получение snapshot с камеры через `camera.async_get_image()` +- Передача base64 изображения в LLM через LangChain `HumanMessage(content=[{"type": "image_url", ...}])` +- Новый tool `get_camera_snapshot` или интеграция с Frigate events + +--- + +### v1.1 — Генерация изображений (GigaChat Kandinsky + YandexART) + +**Приоритет:** Средний +**Сложность:** Средняя +**Зависимости:** нет + +**Что:** +- GigaChat 2.0 имеет встроенную генерацию через Kandinsky +- YandexGPT имеет YandexART API +- Результат: `image` entity или сервис `smartchain.generate_image` + +**Реализация:** +- Для GigaChat: уже поддерживается через API (модель сама решает, когда генерировать) +- Для YandexART: отдельный API вызов +- Сохранение результата в `/media/` или отправка через notify + +--- + +### v1.2 — MCP (Model Context Protocol) + +**Приоритет:** Средний +**Сложность:** Средняя-высокая +**Зависимости:** v0.7 (Assist API) + +**Что:** +- Подключение внешних MCP серверов как дополнительных tools для LLM +- Все официальные интеграции HA поддерживают MCP с 2025.8 +- Примеры: новости, погода из внешних API, todo-листы, файловая система + +**Реализация:** +- HA уже предоставляет MCP tools через `chat_log.llm_api.tools` — если пользователь настроил MCP в HA, tools автоматически доступны через Assist API +- Дополнительно: собственный MCP client для LangChain tools (через `langchain-mcp`) + +--- + +### v1.3 — Доступ к истории состояний + +**Приоритет:** Средний +**Сложность:** Средняя +**Зависимости:** v0.7 (tool calling) + +**Что:** +- LLM может запрашивать историю состояний entity: "Какая температура была вчера?", "Когда последний раз открывали дверь?" +- У Extended OpenAI Conversation это одна из ключевых фич + +**Реализация:** +- Добавить custom tool `get_history(entity_id, start_time, end_time)` +- Использовать `homeassistant.components.recorder` для получения данных +- Форматирование результата в текст для LLM + +--- + +## 4. Фаза 3 — Лидерство (v1.4+) + +Цель: уникальные фичи, которых нет ни у кого. + +### v1.4 — Multi-agent система + +**Приоритет:** Низкий +**Сложность:** Высокая + +**Что:** +- Dispatcher Agent маршрутизирует запросы между специализированными агентами +- Агент управления устройствами (дешёвая быстрая модель) +- Агент аналитики (reasoning модель) +- Агент мультимедиа (vision модель) +- Реализация через LangGraph + +--- + +### v1.5 — Telegram-бот + +**Приоритет:** Низкий +**Сложность:** Низкая + +**Что:** +- Использование SmartChain как backend для Telegram-бота +- Управление домом через Telegram +- Отправка уведомлений с анализом камер + +--- + +### v1.6 — STT/TTS интеграция + +**Приоритет:** Низкий +**Сложность:** Средняя + +**Что:** +- Связка с Yandex SpeechKit для STT/TTS на русском +- GigaChat TTS (когда появится) +- Полный voice pipeline: микрофон → STT → SmartChain → TTS → динамик + +--- + +### v1.7 — Skill-система + +**Приоритет:** Низкий +**Сложность:** Высокая + +**Что:** +- Загружаемые "навыки" из директории (как у Extended OpenAI) +- YAML-описание навыка: промпт + tools + trigger +- Маркетплейс навыков (community-driven) + +--- + +### v1.8 — Prompt caching + +**Приоритет:** Низкий +**Сложность:** Низкая + +**Что:** +- Кэширование системного промпта для ускорения повторных запросов +- Экономия токенов при повторяющихся вызовах +- Реализация: hash промпта → cached response для идентичных запросов + +--- + +## 5. Технические детали по задачам + +### Зависимости для новых провайдеров + +| Провайдер | pip-пакет | LangChain класс | Min version | +|-----------|-----------|-----------------|-------------| +| Ollama | `langchain-ollama` | `ChatOllama` | >=0.3.0 | +| DeepSeek | `langchain-deepseek` | `ChatDeepSeek` | >=0.1.0 | +| Anthropic | `langchain-anthropic` | `ChatAnthropic` | >=0.3.0 | + +### Рекомендуемые локальные модели (для Ollama) + +| Модель | RAM | Лучше всего для | Язык | +|--------|-----|-----------------|------| +| Home-3B-v3 | ~2GB | Device control (97% точность) | EN | +| T-Lite 7B | ~4GB | Русскоязычный чат | RU | +| T-Pro 2.0 32B | ~18GB | Русский reasoning, tool calling | RU | +| Qwen3 4B | ~3GB | Мультиязычный, tool calling | RU/EN/ZH | +| Gemma 3 4B | ~3GB | Vision + чат | EN | + +### Целевые метрики по фазам + +| Фаза | Версия | Тестов | Провайдеров | Ключевая фича | +|-------|--------|--------|-------------|---------------| +| Текущая | 0.6.0 | 34 | 3 | Assist API | +| Фаза 1 | 0.9.0 | ~60 | 6 | AI Task + Ollama + Sub-entries | +| Фаза 2 | 1.3.0 | ~80 | 6 | Vision + MCP + Image Gen | +| Фаза 3 | 1.8.0 | ~100 | 6+ | Multi-agent + Telegram + Skills | + +--- + +```mermaid +gantt + title SmartChain Roadmap 2026 + dateFormat YYYY-MM + axisFormat %b %Y + + section Фаза 1 — Паритет + v0.7 AI Task entity :a1, 2026-03, 2026-04 + v0.8 Ollama + DeepSeek + Anthropic :a2, 2026-04, 2026-05 + v0.9 Sub-entries :a3, 2026-05, 2026-06 + + section Фаза 2 — Дифференциация + v1.0 Vision :b1, 2026-06, 2026-07 + v1.1 Image Generation :b2, 2026-07, 2026-08 + v1.2 MCP :b3, 2026-07, 2026-08 + v1.3 History Access :b4, 2026-08, 2026-09 + + section Фаза 3 — Лидерство + v1.4 Multi-agent :c1, 2026-09, 2026-11 + v1.5 Telegram Bot :c2, 2026-10, 2026-11 + v1.6 STT/TTS :c3, 2026-11, 2026-12 + v1.7 Skill System :c4, 2026-12, 2027-02 +``` diff --git a/hacs.json b/hacs.json index 797bcf4..1621fe2 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { - "name": "GigaChain", - "render_readme" : true + "name": "SmartChain", + "render_readme": true } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5d4e4fc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "ha-smartchain" +version = "0.7.0" +description = "SmartChain — multi-provider LLM conversation agent for Home Assistant" +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP"] + +[dependency-groups] +dev = [ + "pytest-homeassistant-custom-component>=0.13", + "ruff>=0.15", + "langchain-core>=0.3", + "gigachat>=0.1", + "PyTurboJPEG>=2.0", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ + diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..71f5999 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..54ffec7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the SmartChain integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..46f7230 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +"""Fixtures for SmartChain tests.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from langchain_core.messages import AIMessage, AIMessageChunk + +from custom_components.smartchain.const import ( + CONF_API_KEY, + CONF_ENGINE, + DOMAIN, + ID_GIGACHAT, + ID_OPENAI, + ID_YANDEX_GPT, +) + +MOCK_GIGACHAT_DATA = { + CONF_ENGINE: ID_GIGACHAT, + CONF_API_KEY: "test-credentials", +} + +MOCK_YANDEXGPT_DATA = { + CONF_ENGINE: ID_YANDEX_GPT, + CONF_API_KEY: "test-api-key", + "folder_id": "test-folder-id", +} + +MOCK_OPENAI_DATA = { + CONF_ENGINE: ID_OPENAI, + CONF_API_KEY: "test-openai-key", +} + + +@pytest.fixture(autouse=True) +async def setup_ha_components(hass: HomeAssistant) -> None: + """Set up required HA components for all tests.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + await hass.async_block_till_done() + + +async def _mock_astream(messages): + """Async generator that yields a single AIMessageChunk.""" + yield AIMessageChunk(content="Test response from LLM") + + +@pytest.fixture +def mock_llm_client(): + """Create a mock LLM client with astream support.""" + client = MagicMock() + client.invoke.return_value = AIMessage(content="Test response from LLM") + client.astream = MagicMock(side_effect=_mock_astream) + return client + + +@pytest.fixture +def mock_config_entry(): + """Create a mock config entry.""" + entry = MagicMock(spec=ConfigEntry) + entry.entry_id = "test_entry_id" + entry.domain = DOMAIN + entry.data = dict(MOCK_GIGACHAT_DATA) + entry.options = {} + entry.runtime_data = None + entry.unique_id = "GigaChat" + entry.add_update_listener = MagicMock(return_value=lambda: None) + entry.async_on_unload = MagicMock() + return entry + + +@pytest.fixture +def mock_validate_client(): + """Mock validate_client to skip actual API calls.""" + with patch( + "custom_components.smartchain.config_flow.validate_client", + new_callable=AsyncMock, + ) as mock: + yield mock + + +@pytest.fixture +def mock_get_client(mock_llm_client): + """Mock get_client to return a fake LLM client.""" + with patch( + "custom_components.smartchain.get_client", + new_callable=AsyncMock, + return_value=mock_llm_client, + ) as mock: + yield mock diff --git a/tests/test_ai_task.py b/tests/test_ai_task.py new file mode 100644 index 0000000..ae802e6 --- /dev/null +++ b/tests/test_ai_task.py @@ -0,0 +1,194 @@ +"""Tests for SmartChain AI Task entity.""" + +from unittest.mock import MagicMock + +import pytest +from homeassistant.components import ai_task +from homeassistant.components.conversation.chat_log import ( + AssistantContent, + SystemContent, + UserContent, +) +from homeassistant.exceptions import HomeAssistantError +from langchain_core.messages import AIMessageChunk + +from custom_components.smartchain.ai_task import SmartChainAITaskEntity +from custom_components.smartchain.const import CONF_ENGINE, ID_GIGACHAT + + +def _make_chat_log(conversation_id="test-conv-id"): + """Create a mock ChatLog for AI Task tests.""" + chat_log = MagicMock() + chat_log.conversation_id = conversation_id + chat_log.content = [ + SystemContent(content="You are a Home Assistant expert."), + UserContent(content="Summarize the weather"), + ] + chat_log.llm_api = None + chat_log.unresponded_tool_results = False + + async def _mock_add_delta_stream(agent_id, stream): + collected = "" + async for delta in stream: + if "content" in delta: + collected += delta["content"] + content = AssistantContent(agent_id=agent_id, content=collected) + chat_log.content.append(content) + yield content + + chat_log.async_add_delta_content_stream = _mock_add_delta_stream + return chat_log + + +def _make_gen_data_task(instructions="Summarize the weather", structure=None): + """Create a GenDataTask.""" + return ai_task.GenDataTask( + name="test_task", + instructions=instructions, + structure=structure, + ) + + +@pytest.fixture +def ai_task_entity(mock_llm_client): + """Create a SmartChainAITaskEntity.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"} + entry.options = {} + entry.runtime_data = mock_llm_client + ent = SmartChainAITaskEntity(entry) + ent.hass = MagicMock() + ent._attr_entity_id = "ai_task.smartchain_test" + return ent + + +async def test_ai_task_entity_init(ai_task_entity) -> None: + """Test AI Task entity initialization.""" + assert ai_task_entity._attr_unique_id == "test_entry_ai_task" + assert ( + ai_task_entity._attr_supported_features + == ai_task.AITaskEntityFeature.GENERATE_DATA + ) + assert ai_task_entity._attr_has_entity_name is True + + +async def test_generate_data_basic(ai_task_entity) -> None: + """Test basic data generation returns text result.""" + chat_log = _make_chat_log() + task = _make_gen_data_task() + + result = await ai_task_entity._async_generate_data(task, chat_log) + + assert isinstance(result, ai_task.GenDataTaskResult) + assert result.conversation_id == "test-conv-id" + assert result.data == "Test response from LLM" + + +async def test_generate_data_structured_json(ai_task_entity) -> None: + """Test structured output parses JSON response.""" + import voluptuous as vol + + # Mock client to return JSON + async def _json_astream(messages): + yield AIMessageChunk(content='{"temperature": 22, "condition": "sunny"}') + + ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_json_astream) + + chat_log = _make_chat_log() + task = _make_gen_data_task( + instructions="Get weather data", + structure=vol.Schema( + { + vol.Required("temperature"): int, + vol.Required("condition"): str, + } + ), + ) + + result = await ai_task_entity._async_generate_data(task, chat_log) + + assert isinstance(result, ai_task.GenDataTaskResult) + assert result.data == {"temperature": 22, "condition": "sunny"} + + +async def test_generate_data_structured_invalid_json(ai_task_entity) -> None: + """Test structured output raises error on invalid JSON.""" + import voluptuous as vol + + async def _bad_astream(messages): + yield AIMessageChunk(content="not json at all") + + ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_bad_astream) + + chat_log = _make_chat_log() + task = _make_gen_data_task( + instructions="Get data", + structure=vol.Schema({vol.Required("key"): str}), + ) + + with pytest.raises(HomeAssistantError, match="Failed to parse"): + await ai_task_entity._async_generate_data(task, chat_log) + + +async def test_generate_data_llm_error(ai_task_entity) -> None: + """Test that LLM errors are raised as HomeAssistantError.""" + + async def _error_astream(messages): + raise RuntimeError("LLM API down") + yield # noqa: F841 + + ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_error_astream) + + chat_log = _make_chat_log() + task = _make_gen_data_task() + + with pytest.raises(HomeAssistantError, match="AI Task error"): + await ai_task_entity._async_generate_data(task, chat_log) + + +async def test_generate_data_with_tools(ai_task_entity) -> None: + """Test that tools from llm_api are bound to client.""" + import voluptuous as vol + + class MockTool: + name = "HassTurnOn" + description = "Turn on" + parameters = vol.Schema({vol.Required("entity_id"): str}) + + mock_api = MagicMock() + mock_api.tools = [MockTool()] + + chat_log = _make_chat_log() + chat_log.llm_api = mock_api + + bound_client = MagicMock() + + async def _bound_astream(messages): + yield AIMessageChunk(content="Done, light is on") + + bound_client.astream = MagicMock(side_effect=_bound_astream) + ai_task_entity.entry.runtime_data.bind_tools = MagicMock(return_value=bound_client) + + task = _make_gen_data_task(instructions="Turn on kitchen light") + + result = await ai_task_entity._async_generate_data(task, chat_log) + + ai_task_entity.entry.runtime_data.bind_tools.assert_called_once() + assert result.data == "Done, light is on" + + +async def test_generate_data_empty_response(ai_task_entity) -> None: + """Test handling of empty LLM response.""" + + async def _empty_astream(messages): + yield AIMessageChunk(content="") + + ai_task_entity.entry.runtime_data.astream = MagicMock(side_effect=_empty_astream) + + chat_log = _make_chat_log() + task = _make_gen_data_task() + + result = await ai_task_entity._async_generate_data(task, chat_log) + + assert result.data == "" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..d53fe4b --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,212 @@ +"""Tests for SmartChain config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from gigachat.exceptions import ResponseError +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from httpx import ConnectError + +from custom_components.smartchain.const import ( + CONF_API_KEY, + CONF_ENGINE, + CONF_FOLDER_ID, + CONF_SKIP_VALIDATION, + DOMAIN, + ID_GIGACHAT, + ID_OPENAI, + ID_YANDEX_GPT, +) + +pytestmark = pytest.mark.usefixtures("enable_custom_integrations") + + +async def test_step_user_shows_engine_form(hass: HomeAssistant) -> None: + """Test that the user step shows engine selection form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_step_user_selects_gigachat(hass: HomeAssistant) -> None: + """Test selecting GigaChat engine shows API key form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_GIGACHAT} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == ID_GIGACHAT + + +async def test_step_user_selects_yandexgpt(hass: HomeAssistant) -> None: + """Test selecting YandexGPT engine shows API key + folder form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_YANDEX_GPT} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == ID_YANDEX_GPT + + +async def test_step_user_selects_openai(hass: HomeAssistant) -> None: + """Test selecting OpenAI engine shows API key form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_OPENAI} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == ID_OPENAI + + +async def test_gigachat_full_flow( + hass: HomeAssistant, mock_validate_client: AsyncMock +) -> None: + """Test full GigaChat config flow creates entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_GIGACHAT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "test-credentials", CONF_SKIP_VALIDATION: False}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "GigaChat" + assert result["data"][CONF_ENGINE] == ID_GIGACHAT + assert result["data"][CONF_API_KEY] == "test-credentials" + + +async def test_yandexgpt_full_flow( + hass: HomeAssistant, mock_validate_client: AsyncMock +) -> None: + """Test full YandexGPT config flow creates entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_YANDEX_GPT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test-api-key", + CONF_FOLDER_ID: "test-folder", + CONF_SKIP_VALIDATION: False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "YandexGPT" + assert result["data"][CONF_ENGINE] == ID_YANDEX_GPT + + +async def test_openai_full_flow( + hass: HomeAssistant, mock_validate_client: AsyncMock +) -> None: + """Test full OpenAI config flow creates entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_OPENAI} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "test-openai-key", CONF_SKIP_VALIDATION: False}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenAI" + assert result["data"][CONF_ENGINE] == ID_OPENAI + + +async def test_gigachat_connect_error(hass: HomeAssistant) -> None: + """Test GigaChat config flow handles connection error.""" + with patch( + "custom_components.smartchain.config_flow.validate_client", + side_effect=ConnectError("Connection failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_GIGACHAT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bad-credentials", CONF_SKIP_VALIDATION: False}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_gigachat_invalid_response(hass: HomeAssistant) -> None: + """Test GigaChat config flow handles invalid response.""" + with patch( + "custom_components.smartchain.config_flow.validate_client", + side_effect=ResponseError("Unauthorized"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_GIGACHAT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bad-credentials", CONF_SKIP_VALIDATION: False}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_response"} + + +async def test_gigachat_unknown_error(hass: HomeAssistant) -> None: + """Test GigaChat config flow handles unknown error.""" + with patch( + "custom_components.smartchain.config_flow.validate_client", + side_effect=RuntimeError("Something unexpected"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_GIGACHAT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bad-credentials", CONF_SKIP_VALIDATION: False}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_skip_validation( + hass: HomeAssistant, +) -> None: + """Test config flow with skip_validation=True skips API call.""" + with patch( + "custom_components.smartchain.config_flow.validate_client", + new_callable=AsyncMock, + ) as mock_validate: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ENGINE: ID_GIGACHAT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "test-credentials", CONF_SKIP_VALIDATION: True}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + mock_validate.assert_called_once() diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..f822888 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,520 @@ +"""Tests for SmartChain conversation entity.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.conversation import ConversationInput, ConversationResult +from homeassistant.components.conversation.chat_log import ( + AssistantContent, + SystemContent, + ToolResultContent, + UserContent, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent, llm +from langchain_core.messages import ( + AIMessage, + AIMessageChunk, + HumanMessage, + SystemMessage, + ToolMessage, +) + +from custom_components.smartchain.conversation import ( + SmartChainConversationEntity, + _async_langchain_stream, + _chatlog_to_langchain, + _ha_tool_to_dict, +) +from custom_components.smartchain.const import ( + CONF_CHAT_HISTORY, + CONF_ENGINE, + CONF_LLM_HASS_API, + CONF_PROCESS_BUILTIN_SENTENCES, + CONF_PROMPT, + ID_GIGACHAT, +) + + +def _make_input(text="Hello, assistant!", conversation_id=None): + """Create a ConversationInput with correct signature.""" + return ConversationInput( + text=text, + context=Context(), + conversation_id=conversation_id, + device_id=None, + satellite_id=None, + language="ru", + agent_id="test_agent", + ) + + +def _make_chat_log(conversation_id="test-conv-id", user_text="Hello, assistant!"): + """Create a mock ChatLog with proper content list and streaming support.""" + chat_log = MagicMock() + chat_log.conversation_id = conversation_id + chat_log.content = [ + SystemContent(content=""), + UserContent(content=user_text), + ] + chat_log.async_add_assistant_content_without_tools = MagicMock() + chat_log.llm_api = None + chat_log.unresponded_tool_results = False + + async def _mock_add_delta_stream(agent_id, stream): + collected = "" + async for delta in stream: + if "content" in delta: + collected += delta["content"] + content = AssistantContent(agent_id=agent_id, content=collected) + chat_log.content.append(content) + yield content + + chat_log.async_add_delta_content_stream = _mock_add_delta_stream + return chat_log + + +@pytest.fixture +def mock_entry(mock_llm_client): + """Create a mock config entry with runtime_data.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"} + entry.options = { + CONF_PROMPT: "You are a test assistant.", + CONF_CHAT_HISTORY: True, + CONF_PROCESS_BUILTIN_SENTENCES: False, + } + entry.runtime_data = mock_llm_client + return entry + + +@pytest.fixture +def entity(hass: HomeAssistant, mock_entry): + """Create a SmartChainConversationEntity.""" + ent = SmartChainConversationEntity(mock_entry) + ent.hass = hass + return ent + + +@pytest.fixture +def user_input(): + """Create a ConversationInput.""" + return _make_input() + + +@pytest.fixture +def mock_chat_log(): + """Create a mock ChatLog.""" + return _make_chat_log() + + +async def test_handle_message_basic( + hass: HomeAssistant, entity, user_input, mock_chat_log +) -> None: + """Test basic _async_handle_message returns a response.""" + result = await entity._async_handle_message(user_input, mock_chat_log) + + assert isinstance(result, ConversationResult) + assert result.response.speech["plain"]["speech"] == "Test response from LLM" + assert result.conversation_id == mock_chat_log.conversation_id + + +async def test_handle_message_sets_system_prompt( + hass: HomeAssistant, entity, user_input, mock_chat_log +) -> None: + """Test that system prompt is set in ChatLog.""" + await entity._async_handle_message(user_input, mock_chat_log) + + assert isinstance(mock_chat_log.content[0], SystemContent) + assert "test assistant" in mock_chat_log.content[0].content + + +async def test_handle_message_sends_correct_messages_to_llm( + hass: HomeAssistant, entity, user_input, mock_chat_log +) -> None: + """Test that LLM receives correct LangChain messages from ChatLog.""" + await entity._async_handle_message(user_input, mock_chat_log) + + call_args = entity.entry.runtime_data.astream.call_args[0][0] + assert len(call_args) == 2 # system + human + assert isinstance(call_args[0], SystemMessage) + assert isinstance(call_args[1], HumanMessage) + assert call_args[1].content == "Hello, assistant!" + + +async def test_handle_message_with_history( + hass: HomeAssistant, entity, user_input +) -> None: + """Test that ChatLog history is converted to LangChain messages.""" + chat_log = _make_chat_log() + chat_log.content = [ + SystemContent(content=""), + UserContent(content="First message"), + AssistantContent(agent_id="test", content="First response"), + UserContent(content="Hello, assistant!"), + ] + + await entity._async_handle_message(user_input, chat_log) + + call_args = entity.entry.runtime_data.astream.call_args[0][0] + assert len(call_args) == 4 # system + human + ai + human + assert isinstance(call_args[0], SystemMessage) + assert isinstance(call_args[1], HumanMessage) + assert call_args[1].content == "First message" + assert isinstance(call_args[2], AIMessage) + assert call_args[2].content == "First response" + assert isinstance(call_args[3], HumanMessage) + assert call_args[3].content == "Hello, assistant!" + + +async def test_handle_message_history_disabled( + hass: HomeAssistant, mock_llm_client, user_input +) -> None: + """Test that only current message is sent when history disabled.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"} + entry.options = { + CONF_PROMPT: "Test prompt.", + CONF_CHAT_HISTORY: False, + CONF_PROCESS_BUILTIN_SENTENCES: False, + } + entry.runtime_data = mock_llm_client + + ent = SmartChainConversationEntity(entry) + ent.hass = hass + + chat_log = _make_chat_log() + chat_log.content = [ + SystemContent(content=""), + UserContent(content="Old message"), + AssistantContent(agent_id="test", content="Old response"), + UserContent(content="Hello, assistant!"), + ] + + await ent._async_handle_message(user_input, chat_log) + + call_args = mock_llm_client.astream.call_args[0][0] + assert len(call_args) == 2 + assert isinstance(call_args[0], SystemMessage) + assert isinstance(call_args[1], HumanMessage) + assert call_args[1].content == "Hello, assistant!" + + +async def test_handle_message_llm_error( + hass: HomeAssistant, entity, user_input, mock_chat_log +) -> None: + """Test _async_handle_message handles LLM errors gracefully.""" + + async def _error_stream(messages): + raise RuntimeError("API Error") + yield # noqa: F841 + + entity.entry.runtime_data.astream = MagicMock(side_effect=_error_stream) + + result = await entity._async_handle_message(user_input, mock_chat_log) + + assert isinstance(result, ConversationResult) + assert result.response.error_code == intent.IntentResponseErrorCode.UNKNOWN + assert "API Error" in result.response.speech["plain"]["speech"] + + +async def test_handle_message_with_builtin_not_recognized( + hass: HomeAssistant, mock_llm_client, user_input, mock_chat_log +) -> None: + """Test delegates to HA default agent, falls back to LLM when not recognized.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"} + entry.options = { + CONF_PROMPT: "Test prompt.", + CONF_CHAT_HISTORY: True, + CONF_PROCESS_BUILTIN_SENTENCES: True, + } + entry.runtime_data = mock_llm_client + + mock_default_response = MagicMock(spec=ConversationResult) + mock_default_response.response = MagicMock() + mock_default_response.response.intent = None + + mock_default_agent = AsyncMock() + mock_default_agent.async_process.return_value = mock_default_response + + ent = SmartChainConversationEntity(entry) + ent.hass = hass + + with patch( + "homeassistant.components.conversation.agent_manager.async_get_agent", + return_value=mock_default_agent, + ): + result = await ent._async_handle_message(user_input, mock_chat_log) + + mock_default_agent.async_process.assert_called_once() + assert result.response.speech["plain"]["speech"] == "Test response from LLM" + + +async def test_handle_message_builtin_recognized( + hass: HomeAssistant, mock_llm_client, user_input, mock_chat_log +) -> None: + """Test returns HA response when builtin sentence recognized.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"} + entry.options = { + CONF_PROMPT: "Test prompt.", + CONF_CHAT_HISTORY: True, + CONF_PROCESS_BUILTIN_SENTENCES: True, + } + entry.runtime_data = mock_llm_client + + mock_intent_response = MagicMock() + mock_intent_response.intent = MagicMock() + mock_intent_response.speech = {"plain": {"speech": "HA handled this"}} + + mock_default_response = MagicMock(spec=ConversationResult) + mock_default_response.response = mock_intent_response + + mock_default_agent = AsyncMock() + mock_default_agent.async_process.return_value = mock_default_response + + ent = SmartChainConversationEntity(entry) + ent.hass = hass + + with patch( + "homeassistant.components.conversation.agent_manager.async_get_agent", + return_value=mock_default_agent, + ): + result = await ent._async_handle_message(user_input, mock_chat_log) + + mock_llm_client.astream.assert_not_called() + assert result.response is mock_intent_response + + +async def test_supported_languages(hass: HomeAssistant, entity) -> None: + """Test that supported_languages returns a list.""" + languages = entity.supported_languages + assert isinstance(languages, list) + assert len(languages) > 0 + + +async def test_supports_streaming(entity) -> None: + """Test that entity declares streaming support.""" + assert entity._attr_supports_streaming is True + + +def test_chatlog_to_langchain() -> None: + """Test conversion of ChatLog content to LangChain messages.""" + chat_log = MagicMock() + chat_log.content = [ + SystemContent(content="System prompt"), + UserContent(content="User message"), + AssistantContent(agent_id="test", content="AI response"), + UserContent(content="Follow-up"), + ] + + messages = _chatlog_to_langchain(chat_log) + + assert len(messages) == 4 + assert isinstance(messages[0], SystemMessage) + assert messages[0].content == "System prompt" + assert isinstance(messages[1], HumanMessage) + assert messages[1].content == "User message" + assert isinstance(messages[2], AIMessage) + assert messages[2].content == "AI response" + assert isinstance(messages[3], HumanMessage) + assert messages[3].content == "Follow-up" + + +def test_chatlog_to_langchain_skips_empty_assistant() -> None: + """Test that assistant content without text is skipped.""" + chat_log = MagicMock() + chat_log.content = [ + SystemContent(content="Prompt"), + UserContent(content="Hello"), + AssistantContent(agent_id="test", content=None), + ] + + messages = _chatlog_to_langchain(chat_log) + + assert len(messages) == 2 + + +async def test_async_langchain_stream() -> None: + """Test that _async_langchain_stream converts LangChain chunks to HA deltas.""" + client = MagicMock() + + async def _fake_astream(messages): + yield AIMessageChunk(content="Hello") + yield AIMessageChunk(content=" world") + + client.astream = _fake_astream + + deltas = [] + async for delta in _async_langchain_stream(client, []): + deltas.append(delta) + + assert len(deltas) == 2 + assert deltas[0] == {"role": "assistant", "content": "Hello"} + assert deltas[1] == {"content": " world"} + + +async def test_async_langchain_stream_skips_empty_chunks() -> None: + """Test that empty chunks are skipped in stream conversion.""" + client = MagicMock() + + async def _fake_astream(messages): + yield AIMessageChunk(content="") + yield AIMessageChunk(content="data") + + client.astream = _fake_astream + + deltas = [] + async for delta in _async_langchain_stream(client, []): + deltas.append(delta) + + assert len(deltas) == 2 + assert deltas[0] == {"role": "assistant"} + assert deltas[1] == {"content": "data"} + + +def test_chatlog_to_langchain_with_tool_calls() -> None: + """Test conversion of ChatLog with tool calls and tool results.""" + tool_input = llm.ToolInput( + tool_name="HassTurnOn", + tool_args={"entity_id": "light.kitchen"}, + id="call_123", + ) + chat_log = MagicMock() + chat_log.content = [ + SystemContent(content="System prompt"), + UserContent(content="Turn on kitchen light"), + AssistantContent( + agent_id="test", + content="", + tool_calls=[tool_input], + ), + ToolResultContent( + agent_id="test", + tool_call_id="call_123", + tool_name="HassTurnOn", + tool_result={"success": True}, + ), + AssistantContent(agent_id="test", content="Done!"), + ] + + messages = _chatlog_to_langchain(chat_log) + + assert len(messages) == 5 + assert isinstance(messages[0], SystemMessage) + assert isinstance(messages[1], HumanMessage) + assert isinstance(messages[2], AIMessage) + assert len(messages[2].tool_calls) == 1 + assert messages[2].tool_calls[0]["name"] == "HassTurnOn" + assert messages[2].tool_calls[0]["id"] == "call_123" + assert isinstance(messages[3], ToolMessage) + assert messages[3].tool_call_id == "call_123" + assert json.loads(messages[3].content) == {"success": True} + assert isinstance(messages[4], AIMessage) + assert messages[4].content == "Done!" + + +def test_ha_tool_to_dict() -> None: + """Test conversion of HA llm.Tool to dict for LangChain.""" + import voluptuous as vol + + class MockTool(llm.Tool): + name = "HassTurnOn" + description = "Turn on a device" + parameters = vol.Schema( + { + vol.Required("entity_id"): str, + } + ) + + async def async_call(self, hass, tool_input, llm_context): + return {"success": True} + + tool = MockTool() + result = _ha_tool_to_dict(tool) + + assert result["name"] == "HassTurnOn" + assert result["description"] == "Turn on a device" + assert "properties" in result["parameters"] + assert "entity_id" in result["parameters"]["properties"] + + +async def test_async_langchain_stream_with_tool_calls() -> None: + """Test that tool_calls from LLM are converted to HA ToolInput in stream.""" + client = MagicMock() + + async def _fake_astream(messages): + yield AIMessageChunk( + content="", + tool_calls=[ + { + "id": "call_1", + "name": "HassTurnOn", + "args": {"entity_id": "light.kitchen"}, + }, + ], + ) + + client.astream = _fake_astream + + deltas = [] + async for delta in _async_langchain_stream(client, []): + deltas.append(delta) + + assert len(deltas) == 1 + assert deltas[0]["role"] == "assistant" + assert "tool_calls" in deltas[0] + assert len(deltas[0]["tool_calls"]) == 1 + tc = deltas[0]["tool_calls"][0] + assert isinstance(tc, llm.ToolInput) + assert tc.tool_name == "HassTurnOn" + assert tc.tool_args == {"entity_id": "light.kitchen"} + assert tc.id == "call_1" + + +async def test_handle_message_with_llm_api( + hass: HomeAssistant, mock_llm_client, user_input +) -> None: + """Test _async_handle_message with LLM Hass API configured.""" + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = {CONF_ENGINE: ID_GIGACHAT, "api_key": "test"} + entry.options = { + CONF_PROMPT: "Test prompt.", + CONF_CHAT_HISTORY: True, + CONF_PROCESS_BUILTIN_SENTENCES: False, + CONF_LLM_HASS_API: "assist", + } + entry.runtime_data = mock_llm_client + + ent = SmartChainConversationEntity(entry) + ent.hass = hass + + chat_log = _make_chat_log() + + with patch.object( + chat_log, + "async_provide_llm_data", + new_callable=AsyncMock, + ) as mock_provide: + chat_log.llm_api = None + await ent._async_handle_message(user_input, chat_log) + + mock_provide.assert_called_once() + assert mock_provide.call_args[0][1] == "assist" + + +async def test_handle_message_no_llm_api_sets_prompt_manually( + hass: HomeAssistant, entity, user_input, mock_chat_log +) -> None: + """Test that without LLM API configured, system prompt is set manually.""" + await entity._async_handle_message(user_input, mock_chat_log) + + assert isinstance(mock_chat_log.content[0], SystemContent) + assert "test assistant" in mock_chat_log.content[0].content diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..84e2786 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,111 @@ +"""Tests for SmartChain integration setup and unload.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.smartchain.const import ( + CONF_API_KEY, + CONF_ENGINE, + DOMAIN, + ID_GIGACHAT, + ID_OPENAI, +) + +pytestmark = pytest.mark.usefixtures("enable_custom_integrations") + + +@pytest.fixture +def mock_gigachat_entry(hass: HomeAssistant): + """Create a mock GigaChat config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ENGINE: ID_GIGACHAT, CONF_API_KEY: "test-credentials"}, + options={}, + unique_id="GigaChat", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_openai_entry(hass: HomeAssistant): + """Create a mock OpenAI config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ENGINE: ID_OPENAI, CONF_API_KEY: "test-openai-key"}, + options={}, + unique_id="OpenAI", + ) + entry.add_to_hass(hass) + return entry + + +async def test_setup_entry_gigachat( + hass: HomeAssistant, mock_gigachat_entry, mock_llm_client +) -> None: + """Test successful setup of GigaChat entry.""" + with patch( + "custom_components.smartchain.get_client", + new_callable=AsyncMock, + return_value=mock_llm_client, + ): + result = await hass.config_entries.async_setup(mock_gigachat_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_gigachat_entry.runtime_data is mock_llm_client + + +async def test_setup_entry_openai( + hass: HomeAssistant, mock_openai_entry, mock_llm_client +) -> None: + """Test successful setup of OpenAI entry.""" + with patch( + "custom_components.smartchain.get_client", + new_callable=AsyncMock, + return_value=mock_llm_client, + ): + result = await hass.config_entries.async_setup(mock_openai_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + assert mock_openai_entry.runtime_data is mock_llm_client + + +async def test_unload_entry( + hass: HomeAssistant, mock_gigachat_entry, mock_llm_client +) -> None: + """Test unloading a config entry.""" + with patch( + "custom_components.smartchain.get_client", + new_callable=AsyncMock, + return_value=mock_llm_client, + ): + await hass.config_entries.async_setup(mock_gigachat_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.async_unload(mock_gigachat_entry.entry_id) + await hass.async_block_till_done() + + assert result is True + + +async def test_setup_creates_conversation_entity( + hass: HomeAssistant, mock_gigachat_entry, mock_llm_client +) -> None: + """Test that setup creates a conversation entity.""" + with patch( + "custom_components.smartchain.get_client", + new_callable=AsyncMock, + return_value=mock_llm_client, + ): + await hass.config_entries.async_setup(mock_gigachat_entry.entry_id) + await hass.async_block_till_done() + + states = [s for s in hass.states.async_all() if s.domain == "conversation"] + assert len(states) >= 2 + entity_ids = [s.entity_id for s in states] + assert any("smartchain" in eid for eid in entity_ids)