From 1a41b9e254b09da40ea46e76bee87d8405e4ae99 Mon Sep 17 00:00:00 2001 From: Saint Date: Thu, 28 May 2026 20:08:39 +0300 Subject: [PATCH 1/4] feat(asr): switch dev transcription to GigaAM via LiteLLM - Replace ASR_URL/ASR_MODEL (Mistral) with LITELLM_URL/LITELLM_MODEL (GigaAM via LiteLLM proxy) - Add env_file (.env.dev) for secrets instead of inline ${VAR:-} which overrides with empty host values - Remove MISTRAL_API_KEY, LLM_API_KEY, JWT_SECRET, API_KEY from environment section (provided by .env.dev) Co-authored-by: Saint Co-authored-by: Claude Opus 4.6 --- docker-compose.dev.yaml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 05b7484..5a3a5c8 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -6,18 +6,15 @@ services: container_name: dialogscribe-dev ports: - "7860:7860" + env_file: + - .env.dev environment: - # Transcription via Mistral Voxtral - - MISTRAL_API_KEY=${MISTRAL_API_KEY:-} - - ASR_URL=https://api.mistral.ai - - ASR_MODEL=voxtral-mini-latest + # Transcription via GigaAM (LiteLLM proxy) + - LITELLM_URL=https://litellm.komolov.synology.me + - LITELLM_MODEL=gigaamv3-generation # LLM for summary / insights / chat (Mistral OpenAI-compat) - - LLM_API_KEY=${LLM_API_KEY:-} - LLM_BASE_URL=https://api.mistral.ai/v1 - LLM_MODEL=mistral-small-latest - # Auth - - JWT_SECRET=${JWT_SECRET:-dev-secret-change-in-prod} - - API_KEY=${API_KEY:-} # Database (SQLite inside container) - DATABASE_URL=sqlite+aiosqlite:////app/data/dialogscribe.db # Server From 9e384f695a165d965ad24b4607fc8fbcc1360340 Mon Sep 17 00:00:00 2001 From: Saint Date: Fri, 29 May 2026 02:07:23 +0300 Subject: [PATCH 2/4] Route all LLM access through GigaChat factory (#5) Switch summary/insights/chat/live-hints/autoflow from the OpenAI-compatible client to GigaChat via create_llm_client(). Replacing every direct LLMClient() instantiation fixes the live-mode 401 (stale OpenAI LLM_API_KEY) and adds an explicit GigaChat SDK timeout (GIGACHAT_TIMEOUT, default 60s) so the initial TLS+OAuth handshake no longer times out ("Failed to fetch" on summary). Also move GIGACHAT_API_KEY out of docker-compose.dev.yaml into the gitignored .env.dev to keep the credential out of version control. Co-authored-by: Saint Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + docker-compose.dev.yaml | 7 ++- gigaam_transcriber/chat.py | 4 +- gigaam_transcriber/llm_cascade.py | 32 ++++++----- gigaam_transcriber/summarizer.py | 91 +++++++++++++++++++++++++++++++ requirements.dev.txt | 1 + routers/analysis.py | 3 +- routers/autoflow.py | 11 ++-- routers/live_hints.py | 4 +- routers/meeting_prep.py | 4 +- routers/saved_transcriptions.py | 4 +- 11 files changed, 130 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 333519b..fa1d12a 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ venv.bak/ # Environment variables .env +.env.dev .env.local .env.*.local diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 5a3a5c8..6fbe3c8 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -12,9 +12,10 @@ services: # Transcription via GigaAM (LiteLLM proxy) - LITELLM_URL=https://litellm.komolov.synology.me - LITELLM_MODEL=gigaamv3-generation - # LLM for summary / insights / chat (Mistral OpenAI-compat) - - LLM_BASE_URL=https://api.mistral.ai/v1 - - LLM_MODEL=mistral-small-latest + # LLM for summary / insights / chat (GigaChat) + # GIGACHAT_API_KEY is injected from .env.dev (gitignored — keep secrets out of VCS) + - GIGACHAT_SCOPE=GIGACHAT_API_CORP + - GIGACHAT_MODEL=GigaChat-Pro # Database (SQLite inside container) - DATABASE_URL=sqlite+aiosqlite:////app/data/dialogscribe.db # Server diff --git a/gigaam_transcriber/chat.py b/gigaam_transcriber/chat.py index 8430c75..fcb2af0 100644 --- a/gigaam_transcriber/chat.py +++ b/gigaam_transcriber/chat.py @@ -9,7 +9,7 @@ import logging from typing import Optional -from gigaam_transcriber.summarizer import LLMClient +from gigaam_transcriber.summarizer import LLMClient, create_llm_client from gigaam_transcriber.context_utils import ( estimate_tokens, find_relevant_chunks, @@ -141,7 +141,7 @@ def chat_with_transcript( For long transcripts, uses compressed context (chunk summaries + relevant chunks). """ if llm_client is None: - llm_client = LLMClient() + llm_client = create_llm_client() if model and model != llm_client.config.model: llm_client.update_config( diff --git a/gigaam_transcriber/llm_cascade.py b/gigaam_transcriber/llm_cascade.py index 984ae88..3323346 100644 --- a/gigaam_transcriber/llm_cascade.py +++ b/gigaam_transcriber/llm_cascade.py @@ -13,7 +13,7 @@ from typing import Optional from gigaam_transcriber.hint_typology import Hint, HintType, HintPriority -from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig +from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig, create_llm_client logger = logging.getLogger(__name__) @@ -98,22 +98,24 @@ def __init__( advisor_config: Optional[LLMClientConfig] = None, ): # Classifier client (fast/cheap model) - if classifier_config: - self._classifier = LLMClient(classifier_config) - else: - cfg = LLMClientConfig() - if DEFAULT_CLASSIFIER_MODEL: - cfg.model = DEFAULT_CLASSIFIER_MODEL - self._classifier = LLMClient(cfg) + self._classifier = self._build_client(classifier_config, DEFAULT_CLASSIFIER_MODEL) # Advisor client (strong model) - if advisor_config: - self._advisor = LLMClient(advisor_config) - else: - cfg = LLMClientConfig() - if DEFAULT_ADVISOR_MODEL: - cfg.model = DEFAULT_ADVISOR_MODEL - self._advisor = LLMClient(cfg) + self._advisor = self._build_client(advisor_config, DEFAULT_ADVISOR_MODEL) + + @staticmethod + def _build_client(config: Optional[LLMClientConfig], default_model: str): + """Build an LLM client. Routes to GigaChat when configured, else OpenAI-compatible. + + The model override only applies to the OpenAI-compatible client; GigaChat + uses its single configured model. + """ + if config: + return LLMClient(config) + client = create_llm_client() + if isinstance(client, LLMClient) and default_model: + client.config.model = default_model + return client def classify(self, fragment: str) -> Optional[ClassificationResult]: """Layer 1: Classify a transcript fragment. diff --git a/gigaam_transcriber/summarizer.py b/gigaam_transcriber/summarizer.py index 56c0d95..61843c4 100644 --- a/gigaam_transcriber/summarizer.py +++ b/gigaam_transcriber/summarizer.py @@ -15,6 +15,13 @@ import markdown as md_lib from openai import OpenAI, APIError, APIConnectionError, RateLimitError, AuthenticationError +try: + from gigachat import GigaChat as GigaChatSDK + from gigachat.models import Chat, Messages, MessagesRole + HAS_GIGACHAT = True +except ImportError: + HAS_GIGACHAT = False + if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -308,6 +315,90 @@ def test_connection(self) -> tuple[bool, str]: return False, f"Неизвестная ошибка: {e}" +class GigaChatLLMClient: + """LLM-клиент для GigaChat API (Sber).""" + + def __init__(self, credentials: str = "", scope: str = "GIGACHAT_API_CORP", + model: str = "GigaChat-Pro"): + self._credentials = credentials or os.getenv("GIGACHAT_API_KEY", "") + self._scope = scope or os.getenv("GIGACHAT_SCOPE", "GIGACHAT_API_CORP") + self._model = model or os.getenv("GIGACHAT_MODEL", "GigaChat-Pro") + # Дефолтный таймаут httpx в gigachat SDK слишком мал для начального + # TLS handshake + OAuth — увеличиваем, иначе ConnectTimeout. + self._timeout = float(os.getenv("GIGACHAT_TIMEOUT", "60")) + self._client = None + + @property + def config(self) -> LLMClientConfig: + return LLMClientConfig( + base_url="gigachat", + api_key=self._credentials, + model=self._model, + ) + + def _get_client(self): + if self._client is None: + if not HAS_GIGACHAT: + raise ImportError("Пакет 'gigachat' не установлен: pip install gigachat") + if not self._credentials: + raise ValueError("GIGACHAT_API_KEY не задан") + self._client = GigaChatSDK( + credentials=self._credentials, + scope=self._scope, + model=self._model, + verify_ssl_certs=False, + timeout=self._timeout, + ) + return self._client + + def update_config(self, base_url: str, api_key: str, model: str) -> None: + self._credentials = api_key + self._model = model or "GigaChat-Pro" + self._client = None + + def call(self, system_prompt: str, user_text: str, max_tokens: int = 4096) -> str: + client = self._get_client() + try: + payload = Chat( + messages=[ + Messages(role=MessagesRole.SYSTEM, content=system_prompt), + Messages(role=MessagesRole.USER, content=user_text), + ], + max_tokens=max_tokens, + temperature=0.3, + ) + response = client.chat(payload) + return response.choices[0].message.content or "" + except Exception as e: + raise RuntimeError(f"GigaChat error: {e}") from e + + def test_connection(self) -> tuple[bool, str]: + if not self._credentials: + return False, "GIGACHAT_API_KEY не задан" + try: + client = self._get_client() + payload = Chat( + messages=[Messages(role=MessagesRole.USER, content="Hi")], + max_tokens=5, + ) + response = client.chat(payload) + if response.choices: + return True, f"GigaChat подключён (модель: {self._model})" + return False, "Пустой ответ" + except Exception as e: + return False, f"Ошибка GigaChat: {e}" + + +def create_llm_client() -> LLMClient | GigaChatLLMClient: + """Создать LLM-клиент на основе env-конфигурации.""" + gigachat_key = os.getenv("GIGACHAT_API_KEY", "") + if gigachat_key and HAS_GIGACHAT: + logger.info("LLM provider: GigaChat") + return GigaChatLLMClient(credentials=gigachat_key) + logger.info("LLM provider: OpenAI-compatible (%s)", _default_base_url()) + return LLMClient() + + # --------------------------------------------------------------------------- # Разделение текста на части # --------------------------------------------------------------------------- diff --git a/requirements.dev.txt b/requirements.dev.txt index af56223..b3f791a 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -11,6 +11,7 @@ fastapi>=0.115.0 uvicorn[standard]>=0.30.0 python-multipart>=0.0.9 openai>=1.0.0 +gigachat>=0.1.0 markdown>=3.5.0 python-docx>=1.1.0 weasyprint>=62.0 diff --git a/routers/analysis.py b/routers/analysis.py index e303d87..dc5bc14 100644 --- a/routers/analysis.py +++ b/routers/analysis.py @@ -17,6 +17,7 @@ ) from gigaam_transcriber.summarizer import ( LLMClient, + create_llm_client, generate_summary, get_available_models, summary_to_html, @@ -29,7 +30,7 @@ router = APIRouter(prefix="/api", tags=["analysis"]) -llm_client = LLMClient() +llm_client = create_llm_client() class SummaryRequest(BaseModel): diff --git a/routers/autoflow.py b/routers/autoflow.py index 2334969..495c968 100644 --- a/routers/autoflow.py +++ b/routers/autoflow.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from gigaam_transcriber.autoflow import run_autoflow -from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig +from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig, create_llm_client from gigaam_transcriber.auth import decode_token from gigaam_transcriber.database import async_session_factory from routers._helpers import SUPPORTED_EXTENSIONS, _map_diarization @@ -71,10 +71,11 @@ async def autoflow_ws(ws: WebSocket): with os.fdopen(fd, "wb") as f: f.write(file_bytes) - llm_config = LLMClientConfig() - if model: - llm_config.model = model - llm_client = LLMClient(llm_config) + llm_client = create_llm_client() + # Применяем выбор модели только для OpenAI-совместимого клиента; + # GigaChat использует свою сконфигурированную модель. + if model and isinstance(llm_client, LLMClient): + llm_client.config.model = model transcriber = ws.app.state.transcriber user_id: str = payload.get("sub", "") diff --git a/routers/live_hints.py b/routers/live_hints.py index 7388f24..69b11c2 100644 --- a/routers/live_hints.py +++ b/routers/live_hints.py @@ -22,7 +22,7 @@ from gigaam_transcriber.exceptions import ASRError, AudioProcessingError from gigaam_transcriber.live_hints_service import HINT_TEMPLATES, AudioAdapter, generate_hints from gigaam_transcriber.models import UserSettings -from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig +from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig, create_llm_client from gigaam_transcriber.event_detector import EventDetector from gigaam_transcriber.accumulator import SessionAccumulator from gigaam_transcriber.llm_cascade import LLMCascade @@ -86,7 +86,7 @@ async def live_hints_ws(ws: WebSocket): logger.warning("Failed to load ASR provider preference for user %s", user_id, exc_info=True) audio_adapter = AudioAdapter(provider_preference=provider_preference) - llm_client = LLMClient(LLMClientConfig()) + llm_client = create_llm_client() loop = asyncio.get_event_loop() # ── Live Advisor Agent components ────────────────────────── diff --git a/routers/meeting_prep.py b/routers/meeting_prep.py index f2a570c..f9364bd 100644 --- a/routers/meeting_prep.py +++ b/routers/meeting_prep.py @@ -6,7 +6,7 @@ from gigaam_transcriber.database import get_db from gigaam_transcriber.limits import check_limit from gigaam_transcriber.models import User -from gigaam_transcriber.summarizer import LLMClient +from gigaam_transcriber.summarizer import LLMClient, create_llm_client from gigaam_transcriber.usage import track_usage from gigaam_transcriber.meeting_prep.schemas import MeetingPrepRequest, MeetingPrepResponse from gigaam_transcriber.meeting_prep.service import generate_meeting_prep @@ -14,7 +14,7 @@ router = APIRouter(prefix="/api", tags=["meeting-prep"]) -llm_client = LLMClient() +llm_client = create_llm_client() def _ensure_llm() -> None: diff --git a/routers/saved_transcriptions.py b/routers/saved_transcriptions.py index dc5fda0..fbda68d 100644 --- a/routers/saved_transcriptions.py +++ b/routers/saved_transcriptions.py @@ -15,14 +15,14 @@ from gigaam_transcriber.limits import check_limit from gigaam_transcriber.mindmap import generate_mindmap_markdown from gigaam_transcriber.models import SavedTranscription, User -from gigaam_transcriber.summarizer import LLMClient, generate_summary +from gigaam_transcriber.summarizer import LLMClient, create_llm_client, generate_summary from gigaam_transcriber.usage import track_usage from routers._helpers import logger router = APIRouter(prefix="/api", tags=["saved-transcriptions"]) -llm_client = LLMClient() +llm_client = create_llm_client() def _ensure_llm() -> None: From 37fe458266608ab738f97c1b830140c8b52516ae Mon Sep 17 00:00:00 2001 From: Saint Date: Fri, 29 May 2026 16:10:45 +0300 Subject: [PATCH 3/4] Live Advisor back to Mistral (OpenAI-compatible) + /info endpoint (#6) - LLMCascade._build_client and live_hints router use LLMClient (Mistral via LLM_BASE_URL/LLM_MODEL/LLM_API_KEY) for the Live Advisor, not GigaChat. Transcription stays on GigaAM; summary/insights/chat stay on GigaChat. - New GET /api/live-hints/info: introspects active providers (ASR / advisor / analysis) so the frontend can show a non-intrusive model badge. Co-authored-by: Saint Co-authored-by: Claude Opus 4.8 --- gigaam_transcriber/llm_cascade.py | 18 +++++---- routers/live_hints.py | 64 ++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/gigaam_transcriber/llm_cascade.py b/gigaam_transcriber/llm_cascade.py index 3323346..304de25 100644 --- a/gigaam_transcriber/llm_cascade.py +++ b/gigaam_transcriber/llm_cascade.py @@ -13,7 +13,7 @@ from typing import Optional from gigaam_transcriber.hint_typology import Hint, HintType, HintPriority -from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig, create_llm_client +from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig logger = logging.getLogger(__name__) @@ -105,17 +105,19 @@ def __init__( @staticmethod def _build_client(config: Optional[LLMClientConfig], default_model: str): - """Build an LLM client. Routes to GigaChat when configured, else OpenAI-compatible. + """Build the live-advisor LLM client. - The model override only applies to the OpenAI-compatible client; GigaChat - uses its single configured model. + The Live Advisor deliberately uses the OpenAI-compatible LLMClient + (e.g. Mistral via LLM_BASE_URL/LLM_MODEL/LLM_API_KEY), NOT GigaChat — + GigaChat produced poor live hints. Summary/insights/chat stay on the + global provider via create_llm_client(). """ if config: return LLMClient(config) - client = create_llm_client() - if isinstance(client, LLMClient) and default_model: - client.config.model = default_model - return client + cfg = LLMClientConfig() + if default_model: + cfg.model = default_model + return LLMClient(cfg) def classify(self, fragment: str) -> Optional[ClassificationResult]: """Layer 1: Classify a transcript fragment. diff --git a/routers/live_hints.py b/routers/live_hints.py index 69b11c2..fef59d6 100644 --- a/routers/live_hints.py +++ b/routers/live_hints.py @@ -22,7 +22,7 @@ from gigaam_transcriber.exceptions import ASRError, AudioProcessingError from gigaam_transcriber.live_hints_service import HINT_TEMPLATES, AudioAdapter, generate_hints from gigaam_transcriber.models import UserSettings -from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig, create_llm_client +from gigaam_transcriber.summarizer import LLMClient, LLMClientConfig from gigaam_transcriber.event_detector import EventDetector from gigaam_transcriber.accumulator import SessionAccumulator from gigaam_transcriber.llm_cascade import LLMCascade @@ -45,6 +45,65 @@ async def get_templates(): ] +def _provider_label(base_url: str) -> str: + """Friendly provider name derived from an LLM base URL.""" + b = (base_url or "").lower() + if "mistral" in b: + return "Mistral" + if "openai" in b: + return "OpenAI" + if b == "gigachat": + return "GigaChat" + return base_url or "LLM" + + +@router.get("/info") +async def get_models_info(): + """Report the currently active models (ASR + Live Advisor LLM) for the UI. + + Introspects the real provider objects rather than hardcoding, so the + indicator always reflects the live configuration. + """ + from gigaam_transcriber.asr_provider import get_asr_provider + + info: dict = {"asr": None, "advisor": None} + + try: + asr = get_asr_provider() + primary = getattr(asr, "_primary", asr) + asr_model = getattr(primary, "_model", "") or "" + info["asr"] = { + "model": asr_model, + "label": "GigaAM" if "gigaam" in asr_model.lower() else (asr_model or "ASR"), + "fallback": getattr(asr, "_secondary_name", None), + } + except Exception: + logger.warning("Failed to introspect ASR provider", exc_info=True) + + try: + advisor_cfg = LLMCascade()._advisor.config + info["advisor"] = { + "provider": _provider_label(advisor_cfg.base_url), + "model": advisor_cfg.model, + } + except Exception: + logger.warning("Failed to introspect advisor LLM", exc_info=True) + + # Analysis LLM (summary / insights / chat / post-meeting) + try: + from gigaam_transcriber.summarizer import create_llm_client + + analysis_cfg = create_llm_client().config + info["analysis"] = { + "provider": _provider_label(analysis_cfg.base_url), + "model": analysis_cfg.model, + } + except Exception: + logger.warning("Failed to introspect analysis LLM", exc_info=True) + + return info + + # ─── WebSocket ───────────────────────────────────────────────── @@ -86,7 +145,8 @@ async def live_hints_ws(ws: WebSocket): logger.warning("Failed to load ASR provider preference for user %s", user_id, exc_info=True) audio_adapter = AudioAdapter(provider_preference=provider_preference) - llm_client = create_llm_client() + # Live Advisor uses the OpenAI-compatible client (Mistral), not GigaChat. + llm_client = LLMClient(LLMClientConfig()) loop = asyncio.get_event_loop() # ── Live Advisor Agent components ────────────────────────── From 1f5047c8821238aa1912188274640df38c5b4075 Mon Sep 17 00:00:00 2001 From: Saint Date: Fri, 29 May 2026 18:04:55 +0300 Subject: [PATCH 4/4] fix(asr): per-request httpx client in LiteLLM ASR (fix Event loop is closed) (#7) The transcriber drives ASR via repeated asyncio.run() calls, each creating and closing a fresh event loop. LiteLLMASRClient created its httpx.AsyncClient in __init__, binding to the first loop -> 'Event loop is closed' on the next call, silently falling back to Mistral (whose intermittent SSL errors surfaced as 502 on /api/transcribe). Now each request opens its own client bound to the current loop; close() is a no-op. GigaAM (LiteLLM) works directly without fallback. Co-authored-by: Saint Co-authored-by: Claude Opus 4.8 --- gigaam_transcriber/litellm_client.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/gigaam_transcriber/litellm_client.py b/gigaam_transcriber/litellm_client.py index beedab0..caf4850 100644 --- a/gigaam_transcriber/litellm_client.py +++ b/gigaam_transcriber/litellm_client.py @@ -25,10 +25,15 @@ def __init__(self) -> None: self._base_url = os.getenv("LITELLM_URL", _DEFAULT_URL).rstrip("/") self._model = os.getenv("LITELLM_MODEL", _DEFAULT_MODEL) self._api_key = os.getenv("LITELLM_API_KEY", os.getenv("LLM_API_KEY", "")) - self._client = httpx.AsyncClient(timeout=300.0) + self._timeout = 300.0 async def close(self) -> None: - await self._client.aclose() + # No persistent client to close: each request opens its own + # httpx.AsyncClient bound to the currently-running event loop. + # The transcriber drives ASR via repeated asyncio.run() calls + # (each creating and then closing a fresh loop), so a client held + # across calls would raise "Event loop is closed" on reuse. + return None async def _post_transcription( self, @@ -43,10 +48,14 @@ async def _post_transcription( for attempt in range(_MAX_RETRIES + 1): try: - response = await self._client.post( - url, headers=headers, files=files, data=data, - ) - response.raise_for_status() + # Fresh client per attempt — binds to the current event loop. + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post( + url, headers=headers, files=files, data=data, + ) + response.raise_for_status() + payload = response.json() + return payload.get("text", "") or "" except httpx.TimeoutException as exc: if attempt == _MAX_RETRIES: raise ASRError( @@ -85,9 +94,6 @@ async def _post_transcription( await asyncio.sleep(backoff) continue - payload = response.json() - return payload.get("text", "") or "" - raise ASRError("LiteLLM ASR: exhausted retries") async def transcribe(