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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ venv.bak/

# Environment variables
.env
.env.dev
.env.local
.env.*.local

Expand Down
20 changes: 9 additions & 11 deletions docker-compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +9 to +18
# Database (SQLite inside container)
- DATABASE_URL=sqlite+aiosqlite:////app/data/dialogscribe.db
# Server
Expand Down
4 changes: 2 additions & 2 deletions gigaam_transcriber/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Comment on lines 142 to 145
if model and model != llm_client.config.model:
llm_client.update_config(
Expand Down
24 changes: 15 additions & 9 deletions gigaam_transcriber/litellm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 18 additions & 14 deletions gigaam_transcriber/llm_cascade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions gigaam_transcriber/summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +326 to +329

@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,
)
Comment on lines +345 to +351
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()
Comment on lines +392 to +399
Comment on lines +318 to +399


# ---------------------------------------------------------------------------
# Разделение текста на части
# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions requirements.dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion routers/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)
from gigaam_transcriber.summarizer import (
LLMClient,
create_llm_client,
generate_summary,
get_available_models,
summary_to_html,
Expand All @@ -29,7 +30,7 @@

router = APIRouter(prefix="/api", tags=["analysis"])

llm_client = LLMClient()
llm_client = create_llm_client()


class SummaryRequest(BaseModel):
Expand Down
11 changes: 6 additions & 5 deletions routers/autoflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", "")
Expand Down
60 changes: 60 additions & 0 deletions routers/live_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment on lines +72 to +78
}
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,
}
Comment on lines +83 to +88
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 ─────────────────────────────────────────────────


Expand Down Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions routers/meeting_prep.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
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
from routers._helpers import logger

router = APIRouter(prefix="/api", tags=["meeting-prep"])

llm_client = LLMClient()
llm_client = create_llm_client()


def _ensure_llm() -> None:
Expand Down
Loading