Spaces:
Sleeping
Sleeping
Commit ·
123f615
1
Parent(s): 42ff9dc
Deploy backend v2 2026-05-23 23:36
Browse files- Dockerfile +7 -32
- README.md +15 -3
- pyproject.toml +0 -32
- server/app/config.py +26 -15
- server/app/main.py +24 -31
- server/app/models/__init__.py +0 -0
- server/app/models/asr.py +0 -44
- server/app/models/parakeet_engine.py +0 -43
- server/app/models/whisper_engine.py +0 -36
- server/app/prompts/_shared_output_contract.md +34 -0
- server/app/prompts/system_ask.md +18 -0
- server/app/prompts/system_generic.md +19 -0
- server/app/prompts/system_pitch.md +19 -0
- server/app/prompts/system_sales.md +20 -0
- server/app/rate_limit.py +32 -0
- server/app/routers/admin.py +0 -47
- server/app/routers/health.py +11 -6
- server/app/routers/suggestion.py +37 -0
- server/app/routers/websocket.py +0 -178
- server/app/schemas/messages.py +37 -49
- server/app/services/audio_processor.py +0 -14
- server/app/services/groq_client.py +116 -165
- server/app/services/session_context.py +0 -11
- server/app/services/transcription.py +0 -124
- server/requirements.txt +8 -0
- uv.lock +0 -0
Dockerfile
CHANGED
|
@@ -1,45 +1,20 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
# ── Install uv ──────────────────────────────────────────────────────
|
| 4 |
-
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
| 5 |
-
|
| 6 |
-
# ── System dependencies ─────────────────────────────────────────────
|
| 7 |
-
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
-
libsndfile1 ffmpeg && \
|
| 9 |
-
rm -rf /var/lib/apt/lists/*
|
| 10 |
-
|
| 11 |
-
# ── Create non-root user (HF Spaces runs as UID 1000) ───────────────
|
| 12 |
RUN useradd -m -u 1000 user
|
| 13 |
-
ENV HOME=/home/user
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
# ── Working directory ────────────────────────────────────────────────
|
| 17 |
WORKDIR $HOME/app
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
RUN uv sync --frozen --no-dev --extra parakeet
|
| 23 |
|
| 24 |
-
|
| 25 |
-
COPY --chown=user server/app/ ./app/
|
| 26 |
|
| 27 |
-
# ── Switch to non-root user ─────────────────────────────────────────
|
| 28 |
USER user
|
| 29 |
|
| 30 |
-
# ── Environment ─────────────────────────────────────────────────────
|
| 31 |
-
ENV PATH="$HOME/app/.venv/bin:$PATH"
|
| 32 |
-
|
| 33 |
-
# Redirect caches to /tmp (writable in HF Spaces)
|
| 34 |
-
ENV HF_HOME=/tmp/hf_cache
|
| 35 |
-
ENV NUMBA_CACHE_DIR=/tmp/numba_cache
|
| 36 |
-
|
| 37 |
-
# Defaults for HF environment
|
| 38 |
-
ENV ASR_MODEL_TYPE=parakeet
|
| 39 |
-
ENV WHISPER_MODEL=base.en
|
| 40 |
-
ENV WHISPER_DEVICE=cpu
|
| 41 |
-
ENV WHISPER_COMPUTE_TYPE=int8
|
| 42 |
-
|
| 43 |
EXPOSE 7860
|
| 44 |
|
| 45 |
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
RUN useradd -m -u 1000 user
|
| 4 |
+
ENV HOME=/home/user \
|
| 5 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 6 |
+
PIP_NO_CACHE_DIR=1 \
|
| 7 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 8 |
|
|
|
|
| 9 |
WORKDIR $HOME/app
|
| 10 |
|
| 11 |
+
COPY --chown=user server/requirements.txt ./
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
|
|
|
| 13 |
|
| 14 |
+
COPY --chown=user server/app ./app
|
|
|
|
| 15 |
|
|
|
|
| 16 |
USER user
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
EXPOSE 7860
|
| 19 |
|
| 20 |
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,9 +1,21 @@
|
|
| 1 |
---
|
| 2 |
-
title: Meeting AI
|
| 3 |
sdk: docker
|
| 4 |
app_port: 7860
|
| 5 |
---
|
| 6 |
|
| 7 |
-
# Meeting AI
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Meeting AI Backend
|
| 3 |
sdk: docker
|
| 4 |
app_port: 7860
|
| 5 |
---
|
| 6 |
|
| 7 |
+
# Meeting AI Backend
|
| 8 |
|
| 9 |
+
Stateless Groq-backed suggestion service for the Meeting AI Chrome extension.
|
| 10 |
+
|
| 11 |
+
Audio transcription happens client-side via the user's own Deepgram key — this
|
| 12 |
+
backend only generates structured AI suggestions from transcript text.
|
| 13 |
+
|
| 14 |
+
Endpoints:
|
| 15 |
+
- `GET /` — landing
|
| 16 |
+
- `GET /health` — liveness
|
| 17 |
+
- `POST /suggestion` — generates a `Suggestion` (requires `X-Api-Key`)
|
| 18 |
+
|
| 19 |
+
Required secrets in Space Settings:
|
| 20 |
+
- `GROQ_API_KEY`
|
| 21 |
+
- `API_KEYS` (comma-separated)
|
pyproject.toml
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
[project]
|
| 2 |
-
name = "meeting-ai-assistant"
|
| 3 |
-
version = "0.2.0"
|
| 4 |
-
requires-python = ">=3.11, <3.12"
|
| 5 |
-
dependencies = [
|
| 6 |
-
"fastapi>=0.111.0",
|
| 7 |
-
"uvicorn[standard]>=0.29.0",
|
| 8 |
-
"pydantic-settings>=2.2.0",
|
| 9 |
-
"python-dotenv>=1.0.0",
|
| 10 |
-
"websockets>=12.0",
|
| 11 |
-
"numpy>=1.24.0",
|
| 12 |
-
"faster-whisper>=1.0.0",
|
| 13 |
-
"groq>=0.9.0",
|
| 14 |
-
]
|
| 15 |
-
|
| 16 |
-
[project.optional-dependencies]
|
| 17 |
-
parakeet = [
|
| 18 |
-
"nemo_toolkit[asr]>=1.23.0",
|
| 19 |
-
"soundfile>=0.12.0",
|
| 20 |
-
]
|
| 21 |
-
|
| 22 |
-
[dependency-groups]
|
| 23 |
-
dev = [
|
| 24 |
-
"httpx>=0.27.0",
|
| 25 |
-
"pytest>=8.0.0",
|
| 26 |
-
"pytest-asyncio>=0.23.0",
|
| 27 |
-
"anyio>=4.0.0",
|
| 28 |
-
]
|
| 29 |
-
|
| 30 |
-
[tool.pytest.ini_options]
|
| 31 |
-
pythonpath = ["server"]
|
| 32 |
-
asyncio_mode = "auto"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/config.py
CHANGED
|
@@ -4,27 +4,34 @@ from pathlib import Path
|
|
| 4 |
|
| 5 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
_ROOT_DIR = _SERVER_DIR.parent # project root
|
| 10 |
|
| 11 |
|
| 12 |
class Settings(BaseSettings):
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
api_keys: str = ""
|
| 17 |
admin_key: str = ""
|
|
|
|
|
|
|
| 18 |
groq_api_key: str = ""
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
model_config = SettingsConfigDict(
|
| 29 |
env_file=(_ROOT_DIR / ".env", _SERVER_DIR / ".env"),
|
| 30 |
extra="ignore",
|
|
@@ -34,5 +41,9 @@ class Settings(BaseSettings):
|
|
| 34 |
def api_keys_list(self) -> list[str]:
|
| 35 |
return [k.strip() for k in self.api_keys.split(",") if k.strip()]
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
settings = Settings()
|
|
|
|
| 4 |
|
| 5 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
|
| 7 |
+
_SERVER_DIR = Path(__file__).parent.parent
|
| 8 |
+
_ROOT_DIR = _SERVER_DIR.parent
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class Settings(BaseSettings):
|
| 12 |
+
# HTTP server
|
| 13 |
+
backend_ws_port: int = 7860
|
| 14 |
+
|
| 15 |
+
# Auth — comma-separated app API keys. Acts as a rate-limit token
|
| 16 |
+
# (extension embeds one), not a security boundary; see ADR 0004.
|
| 17 |
api_keys: str = ""
|
| 18 |
admin_key: str = ""
|
| 19 |
+
|
| 20 |
+
# Groq
|
| 21 |
groq_api_key: str = ""
|
| 22 |
+
groq_model: str = "llama-3.3-70b-versatile"
|
| 23 |
+
groq_timeout_seconds: float = 12.0
|
| 24 |
+
|
| 25 |
+
# Rate limits (slowapi). Strings in slowapi's "N/period" form.
|
| 26 |
+
rate_limit_per_ip: str = "30/minute"
|
| 27 |
+
rate_limit_per_key: str = "500/minute"
|
| 28 |
+
|
| 29 |
+
# CORS — overridable for self-hosters.
|
| 30 |
+
cors_allowed_origins: str = "chrome-extension://*,http://localhost:*"
|
| 31 |
+
|
| 32 |
+
# Optional version label surfaced by /health.
|
| 33 |
+
app_version: str = "2.0.0"
|
| 34 |
+
|
| 35 |
model_config = SettingsConfigDict(
|
| 36 |
env_file=(_ROOT_DIR / ".env", _SERVER_DIR / ".env"),
|
| 37 |
extra="ignore",
|
|
|
|
| 41 |
def api_keys_list(self) -> list[str]:
|
| 42 |
return [k.strip() for k in self.api_keys.split(",") if k.strip()]
|
| 43 |
|
| 44 |
+
@property
|
| 45 |
+
def cors_origins_list(self) -> list[str]:
|
| 46 |
+
return [o.strip() for o in self.cors_allowed_origins.split(",") if o.strip()]
|
| 47 |
+
|
| 48 |
|
| 49 |
settings = Settings()
|
server/app/main.py
CHANGED
|
@@ -1,16 +1,15 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import asyncio
|
| 4 |
import logging
|
| 5 |
-
from contextlib import asynccontextmanager
|
| 6 |
-
from typing import AsyncIterator
|
| 7 |
|
| 8 |
-
from fastapi import FastAPI
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
from app.
|
| 12 |
-
from app.
|
| 13 |
-
from app.routers import
|
| 14 |
|
| 15 |
logging.basicConfig(
|
| 16 |
level=logging.INFO,
|
|
@@ -19,40 +18,34 @@ logging.basicConfig(
|
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
loop = asyncio.get_event_loop()
|
| 27 |
-
await loop.run_in_executor(None, engine.load_model)
|
| 28 |
-
app.state.asr_engine = engine
|
| 29 |
-
logger.info("ASR engine loaded (%s)", type(engine).__name__)
|
| 30 |
-
except Exception:
|
| 31 |
-
logger.exception("Model loading failed")
|
| 32 |
-
|
| 33 |
|
| 34 |
-
@asynccontextmanager
|
| 35 |
-
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
| 36 |
-
app.state.active_sessions: dict = {}
|
| 37 |
-
app.state.asr_engine = None
|
| 38 |
-
asyncio.create_task(_load_models(app))
|
| 39 |
-
yield
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
app = FastAPI(
|
| 43 |
-
title="Meeting AI Assistant",
|
| 44 |
-
version="0.2.0",
|
| 45 |
-
lifespan=lifespan,
|
| 46 |
-
)
|
| 47 |
|
| 48 |
app.add_middleware(
|
| 49 |
CORSMiddleware,
|
| 50 |
-
allow_origins=
|
| 51 |
allow_credentials=True,
|
| 52 |
allow_methods=["*"],
|
| 53 |
allow_headers=["*"],
|
| 54 |
)
|
| 55 |
|
| 56 |
app.include_router(health.router)
|
| 57 |
-
app.include_router(
|
| 58 |
-
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import logging
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
from fastapi import FastAPI, Request
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
from fastapi.responses import JSONResponse
|
| 8 |
+
from slowapi.errors import RateLimitExceeded
|
| 9 |
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.rate_limit import ip_limiter, key_limiter
|
| 12 |
+
from app.routers import health, suggestion
|
| 13 |
|
| 14 |
logging.basicConfig(
|
| 15 |
level=logging.INFO,
|
|
|
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
+
app = FastAPI(
|
| 22 |
+
title="Meeting AI Assistant",
|
| 23 |
+
version=settings.app_version,
|
| 24 |
+
)
|
| 25 |
|
| 26 |
+
# Shared limiters — both attached so SlowAPIMiddleware can find them.
|
| 27 |
+
app.state.limiter = ip_limiter
|
| 28 |
+
app.state.key_limiter = key_limiter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
@app.exception_handler(RateLimitExceeded)
|
| 32 |
+
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
|
| 33 |
+
retry_after = getattr(exc, "retry_after", None)
|
| 34 |
+
headers = {"Retry-After": str(int(retry_after))} if retry_after else {}
|
| 35 |
+
return JSONResponse(
|
| 36 |
+
status_code=429,
|
| 37 |
+
content={"detail": "Rate limit exceeded", "limit": str(exc.detail)},
|
| 38 |
+
headers=headers,
|
| 39 |
+
)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
app.add_middleware(
|
| 43 |
CORSMiddleware,
|
| 44 |
+
allow_origins=settings.cors_origins_list,
|
| 45 |
allow_credentials=True,
|
| 46 |
allow_methods=["*"],
|
| 47 |
allow_headers=["*"],
|
| 48 |
)
|
| 49 |
|
| 50 |
app.include_router(health.router)
|
| 51 |
+
app.include_router(suggestion.router)
|
|
|
server/app/models/__init__.py
DELETED
|
File without changes
|
server/app/models/asr.py
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from abc import ABC, abstractmethod
|
| 4 |
-
from dataclasses import dataclass, field
|
| 5 |
-
|
| 6 |
-
import numpy as np
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
@dataclass
|
| 10 |
-
class ASRResult:
|
| 11 |
-
text: str
|
| 12 |
-
confidence: float = 0.9
|
| 13 |
-
language: str | None = None
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
class ASREngine(ABC):
|
| 17 |
-
@abstractmethod
|
| 18 |
-
def load_model(self) -> None: ...
|
| 19 |
-
|
| 20 |
-
@abstractmethod
|
| 21 |
-
def transcribe_audio(self, audio: np.ndarray, sample_rate: int = 16000) -> ASRResult: ...
|
| 22 |
-
|
| 23 |
-
@abstractmethod
|
| 24 |
-
def is_loaded(self) -> bool: ...
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def get_asr_engine() -> ASREngine:
|
| 28 |
-
from app.config import settings
|
| 29 |
-
|
| 30 |
-
if settings.asr_model_type == "parakeet":
|
| 31 |
-
from app.models.parakeet_engine import ParakeetEngine
|
| 32 |
-
return ParakeetEngine()
|
| 33 |
-
|
| 34 |
-
from app.models.whisper_engine import WhisperEngine
|
| 35 |
-
return WhisperEngine()
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
WHISPER_ALLOWLIST = ("tiny.en", "base.en", "small.en", "medium.en")
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def get_whisper_engine(model_name: str) -> ASREngine:
|
| 42 |
-
"""Return a WhisperEngine configured for the given model name."""
|
| 43 |
-
from app.models.whisper_engine import WhisperEngine
|
| 44 |
-
return WhisperEngine(model_name=model_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/models/parakeet_engine.py
DELETED
|
@@ -1,43 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import tempfile
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
|
| 6 |
-
import numpy as np
|
| 7 |
-
import soundfile as sf
|
| 8 |
-
|
| 9 |
-
from app.models.asr import ASREngine, ASRResult
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
class ParakeetEngine(ASREngine):
|
| 13 |
-
MODEL_NAME = "nvidia/parakeet-tdt-1.1b"
|
| 14 |
-
|
| 15 |
-
def __init__(self) -> None:
|
| 16 |
-
self._model = None
|
| 17 |
-
|
| 18 |
-
def load_model(self) -> None:
|
| 19 |
-
import nemo.collections.asr as nemo_asr # deferred: ~3 GB install
|
| 20 |
-
|
| 21 |
-
self._model = nemo_asr.models.ASRModel.from_pretrained(self.MODEL_NAME)
|
| 22 |
-
self._model.eval()
|
| 23 |
-
|
| 24 |
-
def transcribe_audio(self, audio: np.ndarray, sample_rate: int = 16000) -> ASRResult:
|
| 25 |
-
if self._model is None:
|
| 26 |
-
raise RuntimeError("ParakeetEngine: model not loaded")
|
| 27 |
-
|
| 28 |
-
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
| 29 |
-
tmp_path = Path(tmp.name)
|
| 30 |
-
sf.write(tmp_path, audio, sample_rate)
|
| 31 |
-
|
| 32 |
-
try:
|
| 33 |
-
results = self._model.transcribe([str(tmp_path)])
|
| 34 |
-
finally:
|
| 35 |
-
tmp_path.unlink(missing_ok=True)
|
| 36 |
-
|
| 37 |
-
raw = results[0] if results else ""
|
| 38 |
-
# NeMo may return Hypothesis objects in newer versions
|
| 39 |
-
text = raw.text if hasattr(raw, "text") else str(raw)
|
| 40 |
-
return ASRResult(text=text.strip())
|
| 41 |
-
|
| 42 |
-
def is_loaded(self) -> bool:
|
| 43 |
-
return self._model is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/models/whisper_engine.py
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import numpy as np
|
| 4 |
-
|
| 5 |
-
from app.config import settings
|
| 6 |
-
from app.models.asr import ASREngine, ASRResult
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
class WhisperEngine(ASREngine):
|
| 10 |
-
def __init__(self, model_name: str | None = None) -> None:
|
| 11 |
-
self._model = None
|
| 12 |
-
self._model_name = model_name or settings.whisper_model
|
| 13 |
-
|
| 14 |
-
def load_model(self) -> None:
|
| 15 |
-
from faster_whisper import WhisperModel
|
| 16 |
-
self._model = WhisperModel(
|
| 17 |
-
self._model_name,
|
| 18 |
-
device=settings.whisper_device,
|
| 19 |
-
compute_type="int8",
|
| 20 |
-
cpu_threads=2,
|
| 21 |
-
)
|
| 22 |
-
|
| 23 |
-
def transcribe_audio(self, audio: np.ndarray, sample_rate: int = 16000) -> ASRResult:
|
| 24 |
-
if self._model is None:
|
| 25 |
-
raise RuntimeError("WhisperEngine: model not loaded")
|
| 26 |
-
segments, _ = self._model.transcribe(
|
| 27 |
-
audio,
|
| 28 |
-
beam_size=1,
|
| 29 |
-
language="en",
|
| 30 |
-
condition_on_previous_text=False,
|
| 31 |
-
)
|
| 32 |
-
text = " ".join(seg.text for seg in segments).strip()
|
| 33 |
-
return ASRResult(text=text)
|
| 34 |
-
|
| 35 |
-
def is_loaded(self) -> bool:
|
| 36 |
-
return self._model is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/prompts/_shared_output_contract.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Shared output contract (appended to every system prompt)
|
| 2 |
+
|
| 3 |
+
You MUST respond with a single JSON object and nothing else — no preamble, no code fences, no commentary.
|
| 4 |
+
|
| 5 |
+
Shape:
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
{
|
| 9 |
+
"category": "say-this" | "remember" | "watch-out" | null,
|
| 10 |
+
"headline": string (3–6 words) | null,
|
| 11 |
+
"action": string (1–2 sentences, ≤ 40 words) | null,
|
| 12 |
+
"reasoning": string (one short sentence, ≤ 25 words) | null,
|
| 13 |
+
"triggering_turn": string (verbatim quote of what the OTHER side just said, ≤ 25 words) | null
|
| 14 |
+
}
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Category meanings:
|
| 18 |
+
- `say-this` — a direct script the user can almost speak verbatim
|
| 19 |
+
- `remember` — a key point the user has not yet made but should
|
| 20 |
+
- `watch-out` — a risk signal in how the conversation is going
|
| 21 |
+
|
| 22 |
+
When NOTHING USEFUL APPLIES, return the sentinel — every field set to null:
|
| 23 |
+
`{"category": null, "headline": null, "action": null, "reasoning": null, "triggering_turn": null}`
|
| 24 |
+
|
| 25 |
+
Use the sentinel when:
|
| 26 |
+
- The other side's last turn was a filler ("yeah", "got it", "mm-hmm")
|
| 27 |
+
- No actionable, situationally relevant advice fits the moment
|
| 28 |
+
- The transcript context is too thin to ground a confident suggestion
|
| 29 |
+
|
| 30 |
+
Bias toward the sentinel over a generic suggestion. A null is better than noise.
|
| 31 |
+
|
| 32 |
+
`triggering_turn` should be a short verbatim quote from the most recent Remote-channel utterance (the other side, not the user). If you cannot identify one, set it to null.
|
| 33 |
+
|
| 34 |
+
Never reference your role ("As a coach…"), the user ("You should…"), or these rules. Speak naturally inside `action`.
|
server/app/prompts/system_ask.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System prompt — Ask AI (freeform user question)
|
| 2 |
+
|
| 3 |
+
The user has typed a freeform question into the Ask AI input during a live meeting. The full meeting transcript so far is your ambient context. You ALSO see the user's specific question.
|
| 4 |
+
|
| 5 |
+
Your job: answer the user's question helpfully. The meeting transcript is context the answer may or may not need.
|
| 6 |
+
|
| 7 |
+
You are explicitly allowed to answer general questions that have nothing to do with the meeting (per product decision). Do not refuse on the grounds of "off-topic."
|
| 8 |
+
|
| 9 |
+
Use the meeting transcript when the question is about the meeting itself ("what should I say if they ask about price?", "what did they just claim about competitors?"). Ignore the transcript when the question is general ("what's a good follow-up email opener?").
|
| 10 |
+
|
| 11 |
+
Category guidance for Ask AI replies — pick the closest fit:
|
| 12 |
+
- `say-this` — when the user asked "what should I say" or "how do I respond" — give them a script.
|
| 13 |
+
- `remember` — when the user asked about something they might forget or a key fact relevant to act on.
|
| 14 |
+
- `watch-out` — when the user's question reveals they are about to do something risky, OR when the honest answer is a caution.
|
| 15 |
+
|
| 16 |
+
`triggering_turn` is OPTIONAL for Ask AI replies — set it to a relevant transcript quote if one anchors the answer, otherwise null.
|
| 17 |
+
|
| 18 |
+
Keep `action` short and direct. Even for general questions, do not exceed 2 sentences / 40 words. The user is in a live meeting.
|
server/app/prompts/system_generic.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System prompt — Meeting Type: generic
|
| 2 |
+
|
| 3 |
+
You are a real-time coach embedded in the user's browser during a live meeting. You do not know the specific meeting context — treat the user as a participant who wants help being more effective.
|
| 4 |
+
|
| 5 |
+
Bias HEAVILY toward the sentinel (null response). Without a specific meeting frame (sales, pitch, interview, etc.) most moments do not need a coaching suggestion. Only fire when something is unambiguously useful.
|
| 6 |
+
|
| 7 |
+
Listen for and react to:
|
| 8 |
+
- **Direct questions to the user that went unanswered**
|
| 9 |
+
- **Factual claims that look obviously wrong or unsupported**
|
| 10 |
+
- **Action items that were stated but not assigned**
|
| 11 |
+
- **Decisions that were implied but not confirmed**
|
| 12 |
+
- **The user being interrupted or talked over**
|
| 13 |
+
|
| 14 |
+
Category guidance for `generic`:
|
| 15 |
+
- `say-this` — a concise, neutral response to a direct question.
|
| 16 |
+
- `remember` — a follow-up the user would obviously want but might miss in the moment (e.g., "you haven't agreed on next steps").
|
| 17 |
+
- `watch-out` — a meeting-process risk (action item floated but unowned, decision unclear, key person silent).
|
| 18 |
+
|
| 19 |
+
The North Star is "handle pushback and remember what to say in high-stakes calls and pitches." The generic mode is a fallback — be conservative.
|
server/app/prompts/system_pitch.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System prompt — Meeting Type: pitch
|
| 2 |
+
|
| 3 |
+
You are a real-time coach embedded in the user's browser during a live investor pitch. The user is the founder; the OTHER side is one or more investors (VCs, angels, scouts).
|
| 4 |
+
|
| 5 |
+
Your job: help the user remember forgotten talking points, handle stress-test questions confidently, and avoid common pitch failure modes.
|
| 6 |
+
|
| 7 |
+
Listen for and react to:
|
| 8 |
+
- **Stress-test questions** — TAM math, competition, defensibility, moat, unit economics
|
| 9 |
+
- **Weak answers** — vague metrics, hedged language, no numbers when numbers are expected
|
| 10 |
+
- **Forgotten points** — traction, team, milestone, ask, use of funds
|
| 11 |
+
- **Investor signals** — partner mentions, fund-stage probes, follow-up offers, "send the deck"
|
| 12 |
+
- **Distractions** — investor changes subject, founder gets pulled into the weeds
|
| 13 |
+
|
| 14 |
+
Category guidance for `pitch`:
|
| 15 |
+
- `say-this` — a direct script for handling a specific question (especially TAM, competition, why-now).
|
| 16 |
+
- `remember` — a key point the founder hasn't made yet that fits the current beat (traction number, team credential, recent milestone).
|
| 17 |
+
- `watch-out` — a pitch risk: founder is hedging, investor is disengaging, key metric was missed.
|
| 18 |
+
|
| 19 |
+
Do not invent funding history, customer counts, revenue figures, or team credentials that are not in the transcript. If a number wasn't said, do not make one up.
|
server/app/prompts/system_sales.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# System prompt — Meeting Type: sales
|
| 2 |
+
|
| 3 |
+
You are a real-time coach embedded in the user's browser during a live sales call. The user is the salesperson; the OTHER side is the prospect or customer.
|
| 4 |
+
|
| 5 |
+
Your job: help the user handle pushback, surface buying signals, and remember the next right thing to say. Be tactical, not generic.
|
| 6 |
+
|
| 7 |
+
Listen for and react to:
|
| 8 |
+
- **Pricing objections** — "too expensive", "send me a quote", anchoring low
|
| 9 |
+
- **Decision-maker probes** — "I'll need to check with…", "we'd have to involve…"
|
| 10 |
+
- **Competitive mentions** — "we're looking at X", "currently using Y"
|
| 11 |
+
- **Vague commitment** — "we'll think about it", "send some info", "circle back"
|
| 12 |
+
- **Specific buying signals** — timeline questions, integration questions, contract-length questions
|
| 13 |
+
- **Stalls** — long silences, topic drift, the prospect changes subject
|
| 14 |
+
|
| 15 |
+
Category guidance for `sales`:
|
| 16 |
+
- `say-this` — a direct script for the user to use right now. Especially for handling objections.
|
| 17 |
+
- `remember` — a qualifying question the user hasn't asked, or a benefit they haven't named, that fits the moment.
|
| 18 |
+
- `watch-out` — a sales risk: prospect is going cold, competing vendor mentioned, decision-maker missing.
|
| 19 |
+
|
| 20 |
+
Do not invent product details, pricing, or features that are not present in the transcript. If the user's product hasn't been named, keep suggestions generic to the situation, not the SKU.
|
server/app/rate_limit.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared slowapi limiters and key functions.
|
| 2 |
+
|
| 3 |
+
We run two parallel limiters:
|
| 4 |
+
- per-IP (catches a single abuser even if they have the key)
|
| 5 |
+
- per-app-key (catches mass abuse from a leaked extension key)
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi import Request
|
| 10 |
+
from slowapi import Limiter
|
| 11 |
+
from slowapi.util import get_remote_address
|
| 12 |
+
|
| 13 |
+
from app.config import settings
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _key_from_header(request: Request) -> str:
|
| 17 |
+
# Use the app API key as the limiter key. Falls back to client IP
|
| 18 |
+
# so unauthenticated requests still bucket somewhere sane.
|
| 19 |
+
key = request.headers.get("x-api-key", "")
|
| 20 |
+
return key or get_remote_address(request)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
ip_limiter = Limiter(key_func=get_remote_address)
|
| 24 |
+
key_limiter = Limiter(key_func=_key_from_header)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def per_ip_limit() -> str:
|
| 28 |
+
return settings.rate_limit_per_ip
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def per_key_limit() -> str:
|
| 32 |
+
return settings.rate_limit_per_key
|
server/app/routers/admin.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import secrets
|
| 4 |
-
from typing import Annotated
|
| 5 |
-
|
| 6 |
-
from fastapi import APIRouter, Body, Depends, status
|
| 7 |
-
from pydantic import BaseModel
|
| 8 |
-
|
| 9 |
-
from app.auth import get_admin_key
|
| 10 |
-
|
| 11 |
-
router = APIRouter()
|
| 12 |
-
|
| 13 |
-
# In-memory store: list of {"label": str, "key": str}
|
| 14 |
-
# Phase 2+: persist to a file or database
|
| 15 |
-
_dynamic_keys: list[dict] = []
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class CreateKeyRequest(BaseModel):
|
| 19 |
-
label: str = ""
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class CreateKeyResponse(BaseModel):
|
| 23 |
-
key: str
|
| 24 |
-
label: str
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
class KeySummary(BaseModel):
|
| 28 |
-
label: str
|
| 29 |
-
key_prefix: str
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@router.post("/api-keys", status_code=status.HTTP_201_CREATED, response_model=CreateKeyResponse)
|
| 33 |
-
async def create_api_key(
|
| 34 |
-
body: Annotated[CreateKeyRequest, Body()] = CreateKeyRequest(),
|
| 35 |
-
_: str = Depends(get_admin_key),
|
| 36 |
-
) -> CreateKeyResponse:
|
| 37 |
-
new_key = "key_" + secrets.token_hex(8)
|
| 38 |
-
_dynamic_keys.append({"label": body.label, "key": new_key})
|
| 39 |
-
return CreateKeyResponse(key=new_key, label=body.label)
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
@router.get("/api-keys", response_model=list[KeySummary])
|
| 43 |
-
async def list_api_keys(_: str = Depends(get_admin_key)) -> list[KeySummary]:
|
| 44 |
-
return [
|
| 45 |
-
KeySummary(label=entry["label"], key_prefix=entry["key"][:8] + "...")
|
| 46 |
-
for entry in _dynamic_keys
|
| 47 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/routers/health.py
CHANGED
|
@@ -1,17 +1,22 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter
|
|
|
|
| 4 |
|
| 5 |
from app.config import settings
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
@router.get("/health")
|
| 11 |
-
async def health(
|
| 12 |
return {
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
-
"
|
| 16 |
-
"version": "0.1.0",
|
| 17 |
}
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from fastapi import APIRouter
|
| 4 |
+
from fastapi.responses import PlainTextResponse
|
| 5 |
|
| 6 |
from app.config import settings
|
| 7 |
|
| 8 |
router = APIRouter()
|
| 9 |
|
| 10 |
|
| 11 |
+
@router.get("/", response_class=PlainTextResponse, include_in_schema=False)
|
| 12 |
+
async def root() -> str:
|
| 13 |
+
return "Meeting AI backend — see /health"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
@router.get("/health")
|
| 17 |
+
async def health() -> dict:
|
| 18 |
return {
|
| 19 |
+
"ok": True,
|
| 20 |
+
"version": settings.app_version,
|
| 21 |
+
"groq_configured": bool(settings.groq_api_key),
|
|
|
|
| 22 |
}
|
server/app/routers/suggestion.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 6 |
+
|
| 7 |
+
from app.auth import get_api_key
|
| 8 |
+
from app.rate_limit import ip_limiter, key_limiter, per_ip_limit, per_key_limit
|
| 9 |
+
from app.schemas.messages import SuggestionRequest, SuggestionResponse
|
| 10 |
+
from app.services.groq_client import get_groq_client
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@router.post("/suggestion", response_model=SuggestionResponse)
|
| 18 |
+
@ip_limiter.limit(per_ip_limit())
|
| 19 |
+
@key_limiter.limit(per_key_limit())
|
| 20 |
+
async def suggest(
|
| 21 |
+
request: Request,
|
| 22 |
+
payload: SuggestionRequest,
|
| 23 |
+
_api_key: str = Depends(get_api_key),
|
| 24 |
+
) -> SuggestionResponse:
|
| 25 |
+
if not _has_groq_configured():
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 28 |
+
detail="Suggestion service not configured (missing GROQ_API_KEY)",
|
| 29 |
+
)
|
| 30 |
+
client = get_groq_client()
|
| 31 |
+
return await client.suggest(payload)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _has_groq_configured() -> bool:
|
| 35 |
+
from app.config import settings
|
| 36 |
+
|
| 37 |
+
return bool(settings.groq_api_key)
|
server/app/routers/websocket.py
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
import json
|
| 5 |
-
import logging
|
| 6 |
-
from datetime import datetime, timezone
|
| 7 |
-
from uuid import uuid4
|
| 8 |
-
|
| 9 |
-
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
| 10 |
-
|
| 11 |
-
from app.auth import verify_api_key
|
| 12 |
-
from app.config import settings
|
| 13 |
-
from app.models.asr import WHISPER_ALLOWLIST, get_whisper_engine
|
| 14 |
-
from app.schemas.messages import (
|
| 15 |
-
AuthErrorMsg,
|
| 16 |
-
AuthSuccessMsg,
|
| 17 |
-
ControlFrame,
|
| 18 |
-
ErrorMsg,
|
| 19 |
-
ModelChangedMsg,
|
| 20 |
-
PongMsg,
|
| 21 |
-
ReadyMsg,
|
| 22 |
-
)
|
| 23 |
-
from app.services.audio_processor import AudioProcessor
|
| 24 |
-
from app.services.groq_client import GroqClient
|
| 25 |
-
from app.services.session_context import SessionContext
|
| 26 |
-
from app.services.transcription import TranscriptionService
|
| 27 |
-
|
| 28 |
-
logger = logging.getLogger(__name__)
|
| 29 |
-
router = APIRouter()
|
| 30 |
-
_model_switch_lock = asyncio.Lock()
|
| 31 |
-
|
| 32 |
-
SESSION_TIMEOUT_SECONDS = 7200
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
@router.websocket("/ws")
|
| 36 |
-
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 37 |
-
await websocket.accept()
|
| 38 |
-
session_id: str | None = None
|
| 39 |
-
ts_service: TranscriptionService | None = None
|
| 40 |
-
|
| 41 |
-
try:
|
| 42 |
-
# ── Auth ──────────────────────────────────────────────────────────────
|
| 43 |
-
raw = await websocket.receive_text()
|
| 44 |
-
try:
|
| 45 |
-
data = json.loads(raw)
|
| 46 |
-
except json.JSONDecodeError:
|
| 47 |
-
await websocket.send_text(
|
| 48 |
-
AuthErrorMsg(message="First message must be a JSON auth frame").model_dump_json()
|
| 49 |
-
)
|
| 50 |
-
await websocket.close(code=4001)
|
| 51 |
-
return
|
| 52 |
-
|
| 53 |
-
if data.get("type") != "auth" or not data.get("api_key"):
|
| 54 |
-
await websocket.send_text(
|
| 55 |
-
AuthErrorMsg(message="First message must be {type:auth, api_key:...}").model_dump_json()
|
| 56 |
-
)
|
| 57 |
-
await websocket.close(code=4001)
|
| 58 |
-
return
|
| 59 |
-
|
| 60 |
-
if not verify_api_key(data["api_key"]):
|
| 61 |
-
await websocket.send_text(AuthErrorMsg(message="Invalid API key").model_dump_json())
|
| 62 |
-
await websocket.close(code=4001)
|
| 63 |
-
return
|
| 64 |
-
|
| 65 |
-
session_id = uuid4().hex
|
| 66 |
-
if not hasattr(websocket.app.state, "active_sessions"):
|
| 67 |
-
websocket.app.state.active_sessions = {}
|
| 68 |
-
active_sessions: dict = websocket.app.state.active_sessions
|
| 69 |
-
active_sessions[session_id] = datetime.now(tz=timezone.utc)
|
| 70 |
-
|
| 71 |
-
await websocket.send_text(AuthSuccessMsg(session_id=session_id).model_dump_json())
|
| 72 |
-
await websocket.send_text(ReadyMsg().model_dump_json())
|
| 73 |
-
logger.info("WS session %s opened", session_id)
|
| 74 |
-
|
| 75 |
-
# ── Per-session pipeline ──────────────────────────────────────────────
|
| 76 |
-
asr_engine = getattr(websocket.app.state, "asr_engine", None)
|
| 77 |
-
session_context = SessionContext()
|
| 78 |
-
processor = AudioProcessor()
|
| 79 |
-
groq_client = (
|
| 80 |
-
GroqClient(settings.groq_api_key, context=session_context)
|
| 81 |
-
if settings.groq_api_key
|
| 82 |
-
else None
|
| 83 |
-
)
|
| 84 |
-
ts_service = (
|
| 85 |
-
TranscriptionService(websocket=websocket, engine=asr_engine, groq_client=groq_client)
|
| 86 |
-
if asr_engine
|
| 87 |
-
else None
|
| 88 |
-
)
|
| 89 |
-
if ts_service:
|
| 90 |
-
ts_service.start()
|
| 91 |
-
if groq_client is None:
|
| 92 |
-
logger.warning("Session %s: GROQ_API_KEY not set — suggestions disabled", session_id)
|
| 93 |
-
|
| 94 |
-
# ── Message loop ──────────────────────────────────────────────────────
|
| 95 |
-
while True:
|
| 96 |
-
elapsed = (
|
| 97 |
-
datetime.now(tz=timezone.utc) - active_sessions[session_id]
|
| 98 |
-
).total_seconds()
|
| 99 |
-
if elapsed > SESSION_TIMEOUT_SECONDS:
|
| 100 |
-
logger.info("Session %s exceeded 2h cap, closing", session_id)
|
| 101 |
-
break
|
| 102 |
-
|
| 103 |
-
message = await websocket.receive()
|
| 104 |
-
|
| 105 |
-
if message.get("type") == "websocket.disconnect":
|
| 106 |
-
logger.info("Session %s: client disconnected", session_id)
|
| 107 |
-
break
|
| 108 |
-
|
| 109 |
-
if "bytes" in message and message["bytes"] is not None:
|
| 110 |
-
if processor is not None and ts_service is not None:
|
| 111 |
-
segment = processor.process_chunk(message["bytes"])
|
| 112 |
-
ts_service.enqueue(segment)
|
| 113 |
-
continue
|
| 114 |
-
|
| 115 |
-
if "text" in message and message["text"] is not None:
|
| 116 |
-
try:
|
| 117 |
-
ctrl = ControlFrame.model_validate_json(message["text"])
|
| 118 |
-
except Exception:
|
| 119 |
-
logger.warning(
|
| 120 |
-
"Session %s: unrecognised frame: %s", session_id, message["text"]
|
| 121 |
-
)
|
| 122 |
-
continue
|
| 123 |
-
|
| 124 |
-
if ctrl.type == "ping":
|
| 125 |
-
await websocket.send_text(PongMsg().model_dump_json())
|
| 126 |
-
elif ctrl.type == "stop_session":
|
| 127 |
-
logger.info("Session %s: stop_session received", session_id)
|
| 128 |
-
break
|
| 129 |
-
elif ctrl.type == "set_model":
|
| 130 |
-
model_name = ctrl.model or ""
|
| 131 |
-
if model_name not in WHISPER_ALLOWLIST:
|
| 132 |
-
await websocket.send_text(
|
| 133 |
-
ErrorMsg(
|
| 134 |
-
message=f"Invalid model '{model_name}'. Valid: {WHISPER_ALLOWLIST}",
|
| 135 |
-
code="INVALID_MODEL",
|
| 136 |
-
).model_dump_json()
|
| 137 |
-
)
|
| 138 |
-
elif _model_switch_lock.locked():
|
| 139 |
-
await websocket.send_text(
|
| 140 |
-
ErrorMsg(
|
| 141 |
-
message="Model switch already in progress, try again shortly",
|
| 142 |
-
code="MODEL_SWITCH_BUSY",
|
| 143 |
-
).model_dump_json()
|
| 144 |
-
)
|
| 145 |
-
else:
|
| 146 |
-
asyncio.create_task(
|
| 147 |
-
_switch_model(websocket.app, model_name, websocket, _model_switch_lock)
|
| 148 |
-
)
|
| 149 |
-
|
| 150 |
-
except WebSocketDisconnect:
|
| 151 |
-
logger.info("Session %s disconnected", session_id)
|
| 152 |
-
finally:
|
| 153 |
-
if ts_service is not None:
|
| 154 |
-
await ts_service.stop()
|
| 155 |
-
active_sessions = getattr(websocket.app.state, "active_sessions", {})
|
| 156 |
-
if session_id and session_id in active_sessions:
|
| 157 |
-
del active_sessions[session_id]
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
async def _switch_model(app, model_name: str, websocket: WebSocket, lock: asyncio.Lock) -> None:
|
| 161 |
-
async with lock:
|
| 162 |
-
await websocket.send_text(
|
| 163 |
-
ModelChangedMsg(model=model_name, status="loading").model_dump_json()
|
| 164 |
-
)
|
| 165 |
-
try:
|
| 166 |
-
engine = get_whisper_engine(model_name)
|
| 167 |
-
loop = asyncio.get_event_loop()
|
| 168 |
-
await loop.run_in_executor(None, engine.load_model)
|
| 169 |
-
app.state.asr_engine = engine
|
| 170 |
-
logger.info("Model switched to %s", model_name)
|
| 171 |
-
await websocket.send_text(
|
| 172 |
-
ModelChangedMsg(model=model_name, status="ready").model_dump_json()
|
| 173 |
-
)
|
| 174 |
-
except Exception as exc:
|
| 175 |
-
logger.error("Model switch failed: %s", exc)
|
| 176 |
-
await websocket.send_text(
|
| 177 |
-
ErrorMsg(message=str(exc), code="MODEL_SWITCH_ERROR").model_dump_json()
|
| 178 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/schemas/messages.py
CHANGED
|
@@ -2,65 +2,53 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from typing import Literal
|
| 4 |
|
| 5 |
-
from pydantic import BaseModel
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
# ── Server → Client ──────────────────────────────────────────────────────────
|
| 9 |
|
| 10 |
-
class
|
| 11 |
-
|
| 12 |
-
session_id: str
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class AuthErrorMsg(BaseModel):
|
| 16 |
-
type: Literal["auth_error"] = "auth_error"
|
| 17 |
-
message: str
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
class ReadyMsg(BaseModel):
|
| 21 |
-
type: Literal["ready"] = "ready"
|
| 22 |
-
message: str = "Models loaded"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class TranscriptionMsg(BaseModel):
|
| 26 |
-
type: Literal["transcription"] = "transcription"
|
| 27 |
text: str
|
| 28 |
-
|
| 29 |
-
timestamp: float
|
| 30 |
-
confidence: float
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class SuggestionMsg(BaseModel):
|
| 34 |
-
type: Literal["suggestion"] = "suggestion"
|
| 35 |
-
text: str
|
| 36 |
-
category: Literal["answer", "question", "action", "summary", "flag"]
|
| 37 |
-
context: str = ""
|
| 38 |
-
timestamp: float
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
class ModelChangedMsg(BaseModel):
|
| 42 |
-
type: Literal["model_changed"] = "model_changed"
|
| 43 |
-
model: str
|
| 44 |
-
status: Literal["loading", "ready"]
|
| 45 |
|
| 46 |
|
| 47 |
-
class
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
code: str
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
class PongMsg(BaseModel):
|
| 54 |
-
type: Literal["pong"] = "pong"
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
# ── Client → Server ───────────────────────────────────────────────────────────
|
| 58 |
|
| 59 |
-
class
|
| 60 |
-
|
| 61 |
-
api_key: str
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from typing import Literal
|
| 4 |
|
| 5 |
+
from pydantic import BaseModel, Field, field_validator
|
| 6 |
|
| 7 |
+
MeetingType = Literal["sales", "pitch", "generic"]
|
| 8 |
+
Channel = Literal["remote", "local"]
|
| 9 |
+
TriggerKind = Literal["auto", "manual", "ask"]
|
| 10 |
+
SuggestionCategory = Literal["say-this", "remember", "watch-out"]
|
| 11 |
|
|
|
|
| 12 |
|
| 13 |
+
class TranscriptTurn(BaseModel):
|
| 14 |
+
channel: Channel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
text: str
|
| 16 |
+
t: float = Field(description="Seconds since session start")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
+
class Trigger(BaseModel):
|
| 20 |
+
kind: TriggerKind
|
| 21 |
+
question: str | None = None
|
|
|
|
| 22 |
|
| 23 |
+
@field_validator("question")
|
| 24 |
+
@classmethod
|
| 25 |
+
def _question_only_with_ask(cls, v: str | None, info) -> str | None:
|
| 26 |
+
kind = info.data.get("kind")
|
| 27 |
+
if kind == "ask" and not (v and v.strip()):
|
| 28 |
+
raise ValueError("question is required when trigger.kind == 'ask'")
|
| 29 |
+
if kind != "ask" and v:
|
| 30 |
+
raise ValueError("question must be null unless trigger.kind == 'ask'")
|
| 31 |
+
return v
|
| 32 |
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
class SuggestionRequest(BaseModel):
|
| 35 |
+
meeting_type: MeetingType
|
| 36 |
+
transcript: list[TranscriptTurn] = Field(default_factory=list, max_length=200)
|
| 37 |
+
trigger: Trigger
|
| 38 |
|
|
|
|
| 39 |
|
| 40 |
+
class SuggestionResponse(BaseModel):
|
| 41 |
+
"""All fields null = 'no useful suggestion' sentinel. Client drops sentinels silently."""
|
|
|
|
| 42 |
|
| 43 |
+
category: SuggestionCategory | None = None
|
| 44 |
+
headline: str | None = None
|
| 45 |
+
action: str | None = None
|
| 46 |
+
reasoning: str | None = None
|
| 47 |
+
triggering_turn: str | None = None
|
| 48 |
|
| 49 |
+
@property
|
| 50 |
+
def is_sentinel(self) -> bool:
|
| 51 |
+
return all(
|
| 52 |
+
getattr(self, f) is None
|
| 53 |
+
for f in ("category", "headline", "action", "reasoning")
|
| 54 |
+
)
|
server/app/services/audio_processor.py
DELETED
|
@@ -1,14 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import numpy as np
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class AudioProcessor:
|
| 7 |
-
"""Converts raw PCM bytes to normalized float32 ndarray. VAD is handled client-side."""
|
| 8 |
-
|
| 9 |
-
def process_chunk(self, pcm_bytes: bytes) -> np.ndarray:
|
| 10 |
-
audio = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
| 11 |
-
return np.clip(audio, -1.0, 1.0)
|
| 12 |
-
|
| 13 |
-
def reset(self) -> None:
|
| 14 |
-
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/services/groq_client.py
CHANGED
|
@@ -3,199 +3,150 @@ from __future__ import annotations
|
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
|
| 9 |
-
from
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
{
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
|
| 55 |
|
| 56 |
class GroqClient:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def __init__(
|
| 60 |
-
self,
|
| 61 |
-
api_key: str,
|
| 62 |
-
context: "SessionContext | None" = None,
|
| 63 |
-
_client=None,
|
| 64 |
-
) -> None:
|
| 65 |
if _client is not None:
|
| 66 |
self._client = _client
|
| 67 |
else:
|
| 68 |
from groq import AsyncGroq
|
| 69 |
-
self._client = AsyncGroq(api_key=api_key)
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
if not text.strip():
|
| 82 |
-
return
|
| 83 |
-
self._context.recent.append(text.strip())
|
| 84 |
-
self._context.unsummarized_count += 1
|
| 85 |
-
if self._context.unsummarized_count >= _SUMMARY_TRIGGER:
|
| 86 |
-
task = asyncio.create_task(self._update_summary())
|
| 87 |
-
self._background_tasks.add(task)
|
| 88 |
-
task.add_done_callback(self._background_tasks.discard)
|
| 89 |
-
self._context.unsummarized_count = 0
|
| 90 |
-
|
| 91 |
-
async def get_suggestion(self) -> SuggestionResult | None:
|
| 92 |
-
if not self._context.recent:
|
| 93 |
-
return None
|
| 94 |
-
|
| 95 |
-
now = time.monotonic()
|
| 96 |
-
if now - self._last_suggestion_time < _SUGGESTION_DEBOUNCE_S:
|
| 97 |
-
return None
|
| 98 |
-
self._last_suggestion_time = now
|
| 99 |
-
|
| 100 |
-
summary_section = self._context.summary or "Meeting just started."
|
| 101 |
-
recent_transcript = "\n".join(
|
| 102 |
-
f"[{i + 1}] {seg}" for i, seg in enumerate(self._context.recent)
|
| 103 |
-
)
|
| 104 |
-
user_prompt = (
|
| 105 |
-
f"Meeting context so far:\n{summary_section}\n\n"
|
| 106 |
-
f"Recent transcript:\n{recent_transcript}\n\n"
|
| 107 |
-
"Generate one coaching response."
|
| 108 |
-
)
|
| 109 |
-
|
| 110 |
-
for attempt in range(_MAX_RETRIES):
|
| 111 |
try:
|
| 112 |
-
|
| 113 |
-
model=
|
| 114 |
messages=[
|
| 115 |
-
{"role": "system", "content":
|
| 116 |
-
{"role": "user", "content":
|
| 117 |
],
|
| 118 |
-
temperature=0.
|
| 119 |
-
max_tokens=
|
|
|
|
| 120 |
)
|
| 121 |
-
raw =
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
except Exception as exc:
|
| 127 |
-
|
| 128 |
-
if not should_retry or attempt == _MAX_RETRIES - 1:
|
| 129 |
logger.error("Groq failed after %d attempt(s): %s", attempt + 1, exc)
|
| 130 |
-
return
|
| 131 |
-
wait = 2
|
| 132 |
-
logger.warning("Groq retry %d
|
| 133 |
await asyncio.sleep(wait)
|
| 134 |
|
| 135 |
-
return
|
| 136 |
-
|
| 137 |
-
async def _update_summary(self) -> None:
|
| 138 |
-
if not self._context.recent:
|
| 139 |
-
return
|
| 140 |
-
new_segments = "\n".join(f"- {seg}" for seg in self._context.recent)
|
| 141 |
-
prompt = _SUMMARY_PROMPT.format(
|
| 142 |
-
current_summary=self._context.summary or "No summary yet.",
|
| 143 |
-
new_segments=new_segments,
|
| 144 |
-
)
|
| 145 |
-
try:
|
| 146 |
-
response = await self._client.chat.completions.create(
|
| 147 |
-
model=self.MODEL,
|
| 148 |
-
messages=[{"role": "user", "content": prompt}],
|
| 149 |
-
temperature=0.3,
|
| 150 |
-
max_tokens=400,
|
| 151 |
-
)
|
| 152 |
-
new_summary = (response.choices[0].message.content or "").strip()
|
| 153 |
-
if new_summary:
|
| 154 |
-
self._context.summary = new_summary
|
| 155 |
-
logger.info("Meeting summary updated (%d chars)", len(new_summary))
|
| 156 |
-
except Exception as exc:
|
| 157 |
-
logger.warning("Summary update failed: %s", exc)
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
# ── helpers ────────────────────────────────────────────────────────────────────
|
| 161 |
-
|
| 162 |
-
def _parse_suggestion(raw: str) -> SuggestionResult | None:
|
| 163 |
-
try:
|
| 164 |
-
data = json.loads(raw.strip())
|
| 165 |
-
return _build_result(data)
|
| 166 |
-
except (json.JSONDecodeError, ValueError):
|
| 167 |
-
pass
|
| 168 |
-
|
| 169 |
-
match = re.search(r"\{[^}]+\}", raw, re.DOTALL)
|
| 170 |
-
if match:
|
| 171 |
-
try:
|
| 172 |
-
data = json.loads(match.group())
|
| 173 |
-
return _build_result(data)
|
| 174 |
-
except (json.JSONDecodeError, ValueError):
|
| 175 |
-
pass
|
| 176 |
-
|
| 177 |
-
logger.warning("Could not parse Groq response: %r", raw[:120])
|
| 178 |
-
return None
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def _build_result(data: dict) -> SuggestionResult | None:
|
| 182 |
-
text = str(data.get("text", "")).strip()
|
| 183 |
-
if not text:
|
| 184 |
-
return None
|
| 185 |
-
category = data.get("category", "question")
|
| 186 |
-
if category not in VALID_CATEGORIES:
|
| 187 |
-
category = "question"
|
| 188 |
-
context = str(data.get("context", "")).strip()
|
| 189 |
-
return SuggestionResult(text=text, category=category, context=context)
|
| 190 |
|
| 191 |
|
| 192 |
def _is_retryable(exc: Exception) -> bool:
|
| 193 |
try:
|
| 194 |
from groq import APIStatusError, RateLimitError
|
|
|
|
| 195 |
if isinstance(exc, RateLimitError):
|
| 196 |
return True
|
| 197 |
if isinstance(exc, APIStatusError):
|
| 198 |
return exc.status_code >= 500
|
| 199 |
except ImportError:
|
| 200 |
pass
|
| 201 |
-
return isinstance(exc, (TimeoutError, OSError))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import asyncio
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.schemas.messages import (
|
| 11 |
+
MeetingType,
|
| 12 |
+
SuggestionRequest,
|
| 13 |
+
SuggestionResponse,
|
| 14 |
+
Trigger,
|
| 15 |
+
TranscriptTurn,
|
| 16 |
+
)
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
+
_PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
| 21 |
+
_MAX_RETRIES = 2
|
| 22 |
+
_MAX_TRANSCRIPT_TURNS = 30 # sliding window the model actually sees
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@lru_cache(maxsize=8)
|
| 26 |
+
def _load_prompt(name: str) -> str:
|
| 27 |
+
body = (_PROMPTS_DIR / f"{name}.md").read_text(encoding="utf-8")
|
| 28 |
+
contract = (_PROMPTS_DIR / "_shared_output_contract.md").read_text(encoding="utf-8")
|
| 29 |
+
return f"{body}\n\n---\n\n{contract}"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _system_prompt_for(trigger: Trigger, meeting_type: MeetingType) -> str:
|
| 33 |
+
if trigger.kind == "ask":
|
| 34 |
+
return _load_prompt("system_ask")
|
| 35 |
+
return _load_prompt(f"system_{meeting_type}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _format_transcript(turns: list[TranscriptTurn]) -> str:
|
| 39 |
+
if not turns:
|
| 40 |
+
return "(no transcript yet)"
|
| 41 |
+
window = turns[-_MAX_TRANSCRIPT_TURNS:]
|
| 42 |
+
return "\n".join(
|
| 43 |
+
f"{'You' if t.channel == 'local' else 'Them'}: {t.text}" for t in window
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _build_user_prompt(req: SuggestionRequest) -> str:
|
| 48 |
+
transcript_block = _format_transcript(req.transcript)
|
| 49 |
+
parts = [
|
| 50 |
+
f"Meeting type: {req.meeting_type}",
|
| 51 |
+
"",
|
| 52 |
+
"Transcript so far (most recent at the bottom):",
|
| 53 |
+
transcript_block,
|
| 54 |
+
"",
|
| 55 |
+
]
|
| 56 |
+
if req.trigger.kind == "ask":
|
| 57 |
+
parts.append(f"User's question: {req.trigger.question}")
|
| 58 |
+
elif req.trigger.kind == "manual":
|
| 59 |
+
parts.append("The user manually requested a suggestion right now.")
|
| 60 |
+
else:
|
| 61 |
+
parts.append("The other side just finished a turn. Suggest the best next move.")
|
| 62 |
+
parts.append("")
|
| 63 |
+
parts.append("Respond with the JSON object only.")
|
| 64 |
+
return "\n".join(parts)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _coerce_response(raw: str) -> SuggestionResponse:
|
| 68 |
+
"""Parse Groq's JSON; on any failure, return the sentinel."""
|
| 69 |
+
try:
|
| 70 |
+
data = json.loads(raw)
|
| 71 |
+
except (json.JSONDecodeError, ValueError):
|
| 72 |
+
logger.warning("Groq returned non-JSON; dropping. raw=%r", raw[:200])
|
| 73 |
+
return SuggestionResponse()
|
| 74 |
|
| 75 |
+
try:
|
| 76 |
+
return SuggestionResponse.model_validate(data)
|
| 77 |
+
except Exception as exc:
|
| 78 |
+
logger.warning("Groq JSON failed validation (%s); dropping. data=%r", exc, data)
|
| 79 |
+
return SuggestionResponse()
|
| 80 |
|
| 81 |
|
| 82 |
class GroqClient:
|
| 83 |
+
"""Stateless Groq wrapper. One instance per process; reused across requests."""
|
| 84 |
+
|
| 85 |
+
def __init__(self, api_key: str | None = None, _client=None) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
if _client is not None:
|
| 87 |
self._client = _client
|
| 88 |
else:
|
| 89 |
from groq import AsyncGroq
|
|
|
|
| 90 |
|
| 91 |
+
self._client = AsyncGroq(
|
| 92 |
+
api_key=api_key or settings.groq_api_key,
|
| 93 |
+
timeout=settings.groq_timeout_seconds,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
async def suggest(self, req: SuggestionRequest) -> SuggestionResponse:
|
| 97 |
+
system = _system_prompt_for(req.trigger, req.meeting_type)
|
| 98 |
+
user = _build_user_prompt(req)
|
| 99 |
+
|
| 100 |
+
for attempt in range(_MAX_RETRIES + 1):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
try:
|
| 102 |
+
resp = await self._client.chat.completions.create(
|
| 103 |
+
model=settings.groq_model,
|
| 104 |
messages=[
|
| 105 |
+
{"role": "system", "content": system},
|
| 106 |
+
{"role": "user", "content": user},
|
| 107 |
],
|
| 108 |
+
temperature=0.5,
|
| 109 |
+
max_tokens=300,
|
| 110 |
+
response_format={"type": "json_object"},
|
| 111 |
)
|
| 112 |
+
raw = resp.choices[0].message.content or ""
|
| 113 |
+
parsed = _coerce_response(raw)
|
| 114 |
+
logger.info(
|
| 115 |
+
"suggest meeting=%s trigger=%s category=%s",
|
| 116 |
+
req.meeting_type,
|
| 117 |
+
req.trigger.kind,
|
| 118 |
+
parsed.category,
|
| 119 |
+
)
|
| 120 |
+
return parsed
|
| 121 |
except Exception as exc:
|
| 122 |
+
if not _is_retryable(exc) or attempt == _MAX_RETRIES:
|
|
|
|
| 123 |
logger.error("Groq failed after %d attempt(s): %s", attempt + 1, exc)
|
| 124 |
+
return SuggestionResponse()
|
| 125 |
+
wait = 2**attempt
|
| 126 |
+
logger.warning("Groq retry %d in %ds: %s", attempt + 1, wait, exc)
|
| 127 |
await asyncio.sleep(wait)
|
| 128 |
|
| 129 |
+
return SuggestionResponse()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
def _is_retryable(exc: Exception) -> bool:
|
| 133 |
try:
|
| 134 |
from groq import APIStatusError, RateLimitError
|
| 135 |
+
|
| 136 |
if isinstance(exc, RateLimitError):
|
| 137 |
return True
|
| 138 |
if isinstance(exc, APIStatusError):
|
| 139 |
return exc.status_code >= 500
|
| 140 |
except ImportError:
|
| 141 |
pass
|
| 142 |
+
return isinstance(exc, (TimeoutError, OSError, asyncio.TimeoutError))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
_client_singleton: GroqClient | None = None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def get_groq_client() -> GroqClient:
|
| 149 |
+
global _client_singleton
|
| 150 |
+
if _client_singleton is None:
|
| 151 |
+
_client_singleton = GroqClient()
|
| 152 |
+
return _client_singleton
|
server/app/services/session_context.py
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from collections import deque
|
| 4 |
-
from dataclasses import dataclass, field
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
@dataclass
|
| 8 |
-
class SessionContext:
|
| 9 |
-
summary: str = ""
|
| 10 |
-
recent: deque = field(default_factory=lambda: deque(maxlen=5))
|
| 11 |
-
unsummarized_count: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/app/services/transcription.py
DELETED
|
@@ -1,124 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
import logging
|
| 5 |
-
import time
|
| 6 |
-
|
| 7 |
-
import numpy as np
|
| 8 |
-
from fastapi import WebSocket
|
| 9 |
-
|
| 10 |
-
from app.models.asr import ASREngine
|
| 11 |
-
from app.schemas.messages import ErrorMsg, SuggestionMsg, TranscriptionMsg
|
| 12 |
-
from app.services.groq_client import GroqClient
|
| 13 |
-
|
| 14 |
-
logger = logging.getLogger(__name__)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class TranscriptionService:
|
| 18 |
-
def __init__(
|
| 19 |
-
self,
|
| 20 |
-
websocket: WebSocket,
|
| 21 |
-
engine: ASREngine,
|
| 22 |
-
groq_client: GroqClient | None = None,
|
| 23 |
-
) -> None:
|
| 24 |
-
self._ws = websocket
|
| 25 |
-
self._engine = engine
|
| 26 |
-
self._groq = groq_client
|
| 27 |
-
self._queue: asyncio.Queue[np.ndarray] = asyncio.Queue(maxsize=3)
|
| 28 |
-
self._worker_task: asyncio.Task | None = None
|
| 29 |
-
self._pending_suggestions: set[asyncio.Task] = set()
|
| 30 |
-
|
| 31 |
-
def start(self) -> None:
|
| 32 |
-
self._worker_task = asyncio.create_task(self._worker())
|
| 33 |
-
|
| 34 |
-
async def stop(self) -> None:
|
| 35 |
-
if self._worker_task:
|
| 36 |
-
self._worker_task.cancel()
|
| 37 |
-
try:
|
| 38 |
-
await self._worker_task
|
| 39 |
-
except asyncio.CancelledError:
|
| 40 |
-
pass
|
| 41 |
-
for task in list(self._pending_suggestions):
|
| 42 |
-
task.cancel()
|
| 43 |
-
if self._pending_suggestions:
|
| 44 |
-
await asyncio.gather(*self._pending_suggestions, return_exceptions=True)
|
| 45 |
-
|
| 46 |
-
def enqueue(self, audio: np.ndarray) -> None:
|
| 47 |
-
if not self._engine or not self._engine.is_loaded():
|
| 48 |
-
return
|
| 49 |
-
if self._queue.full():
|
| 50 |
-
try:
|
| 51 |
-
self._queue.get_nowait()
|
| 52 |
-
self._queue.task_done()
|
| 53 |
-
except asyncio.QueueEmpty:
|
| 54 |
-
pass
|
| 55 |
-
try:
|
| 56 |
-
self._queue.put_nowait(audio)
|
| 57 |
-
except asyncio.QueueFull:
|
| 58 |
-
pass
|
| 59 |
-
|
| 60 |
-
async def _worker(self) -> None:
|
| 61 |
-
while True:
|
| 62 |
-
audio = await self._queue.get()
|
| 63 |
-
try:
|
| 64 |
-
await self._process(audio)
|
| 65 |
-
except Exception as exc:
|
| 66 |
-
logger.exception("Worker error: %s", exc)
|
| 67 |
-
finally:
|
| 68 |
-
self._queue.task_done()
|
| 69 |
-
|
| 70 |
-
async def _process(self, audio: np.ndarray) -> None:
|
| 71 |
-
await self._ws.send_text(
|
| 72 |
-
TranscriptionMsg(
|
| 73 |
-
text="", is_final=False, timestamp=time.time(), confidence=0.0
|
| 74 |
-
).model_dump_json()
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
t0 = time.perf_counter()
|
| 78 |
-
try:
|
| 79 |
-
loop = asyncio.get_event_loop()
|
| 80 |
-
result = await loop.run_in_executor(None, self._engine.transcribe_audio, audio)
|
| 81 |
-
except Exception as exc:
|
| 82 |
-
logger.exception("ASR error: %s", exc)
|
| 83 |
-
await self._ws.send_text(
|
| 84 |
-
ErrorMsg(message=str(exc), code="ASR_ERROR").model_dump_json()
|
| 85 |
-
)
|
| 86 |
-
return
|
| 87 |
-
|
| 88 |
-
latency_ms = (time.perf_counter() - t0) * 1000
|
| 89 |
-
logger.info("ASR %.0f ms | %r", latency_ms, result.text)
|
| 90 |
-
|
| 91 |
-
if not result.text:
|
| 92 |
-
return
|
| 93 |
-
|
| 94 |
-
await self._ws.send_text(
|
| 95 |
-
TranscriptionMsg(
|
| 96 |
-
text=result.text,
|
| 97 |
-
is_final=True,
|
| 98 |
-
timestamp=time.time(),
|
| 99 |
-
confidence=result.confidence,
|
| 100 |
-
).model_dump_json()
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
if self._groq is not None:
|
| 104 |
-
self._groq.add_transcription(result.text)
|
| 105 |
-
task = asyncio.create_task(self._send_suggestion())
|
| 106 |
-
self._pending_suggestions.add(task)
|
| 107 |
-
task.add_done_callback(self._pending_suggestions.discard)
|
| 108 |
-
|
| 109 |
-
async def _send_suggestion(self) -> None:
|
| 110 |
-
assert self._groq is not None
|
| 111 |
-
try:
|
| 112 |
-
suggestion = await self._groq.get_suggestion()
|
| 113 |
-
if suggestion is None:
|
| 114 |
-
return
|
| 115 |
-
await self._ws.send_text(
|
| 116 |
-
SuggestionMsg(
|
| 117 |
-
text=suggestion.text,
|
| 118 |
-
category=suggestion.category,
|
| 119 |
-
context=suggestion.context,
|
| 120 |
-
timestamp=time.time(),
|
| 121 |
-
).model_dump_json()
|
| 122 |
-
)
|
| 123 |
-
except Exception as exc:
|
| 124 |
-
logger.exception("Suggestion pipeline error: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
server/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.29.0
|
| 3 |
+
pydantic>=2.7.0
|
| 4 |
+
pydantic-settings>=2.2.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
slowapi>=0.1.9
|
| 7 |
+
httpx>=0.27.0
|
| 8 |
+
groq>=0.11.0
|
uv.lock
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|