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 05b7484..6fbe3c8 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -6,18 +6,16 @@ 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 - # 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:-} + # Transcription via GigaAM (LiteLLM proxy) + - LITELLM_URL=https://litellm.komolov.synology.me + - LITELLM_MODEL=gigaamv3-generation + # 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/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( diff --git a/gigaam_transcriber/llm_cascade.py b/gigaam_transcriber/llm_cascade.py index 984ae88..304de25 100644 --- a/gigaam_transcriber/llm_cascade.py +++ b/gigaam_transcriber/llm_cascade.py @@ -98,22 +98,26 @@ 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 the live-advisor LLM client. + + 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) + 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/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..fef59d6 100644 --- a/routers/live_hints.py +++ b/routers/live_hints.py @@ -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,6 +145,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) + # Live Advisor uses the OpenAI-compatible client (Mistral), not GigaChat. llm_client = LLMClient(LLMClientConfig()) loop = asyncio.get_event_loop() 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: