Siddu2004-2006 commited on
Commit
123f615
·
1 Parent(s): 42ff9dc

Deploy backend v2 2026-05-23 23:36

Browse files
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
- ENV PATH=/home/user/.local/bin:$PATH
 
 
15
 
16
- # ── Working directory ────────────────────────────────────────────────
17
  WORKDIR $HOME/app
18
 
19
- # ── Install Python deps ─────────────────────────────────────────────
20
- COPY --chown=user pyproject.toml uv.lock* ./
21
-
22
- RUN uv sync --frozen --no-dev --extra parakeet
23
 
24
- # ── Copy application code ───────────────────────────────────────────
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 Assistant
3
  sdk: docker
4
  app_port: 7860
5
  ---
6
 
7
- # Meeting AI Assistant
8
 
9
- Real-time meeting transcription and AI-powered response suggestions.
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Resolve .env relative to this file so it works regardless of CWD.
8
- _SERVER_DIR = Path(__file__).parent.parent # server/
9
- _ROOT_DIR = _SERVER_DIR.parent # project root
10
 
11
 
12
  class Settings(BaseSettings):
13
- backend_ws_port: int = 8000
14
- # Stored as a raw comma-separated string to avoid pydantic-settings JSON
15
- # parsing for list[str] fields, which fails on plain "key1,key2" values.
 
 
16
  api_keys: str = ""
17
  admin_key: str = ""
 
 
18
  groq_api_key: str = ""
19
- whisper_model: str = "base.en" # valid: tiny.en | base.en | small.en | medium.en
20
- whisper_device: str = "cpu"
21
- whisper_compute_type: str = "float32"
22
- asr_model_type: str = "whisper"
23
- vad_threshold: float = 0.5
24
- chunk_duration_ms: int = 300
25
- max_silence_ms: int = 800
26
-
27
- # Checks root .env first, then server/.env — later files win on conflict.
 
 
 
 
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.models.asr import get_asr_engine
12
- from app.routers import admin, health
13
- from app.routers import websocket as ws_router
14
 
15
  logging.basicConfig(
16
  level=logging.INFO,
@@ -19,40 +18,34 @@ logging.basicConfig(
19
 
20
  logger = logging.getLogger(__name__)
21
 
 
 
 
 
22
 
23
- async def _load_models(app: FastAPI) -> None:
24
- try:
25
- engine = get_asr_engine()
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=["chrome-extension://*", "http://localhost:*"],
51
  allow_credentials=True,
52
  allow_methods=["*"],
53
  allow_headers=["*"],
54
  )
55
 
56
  app.include_router(health.router)
57
- app.include_router(ws_router.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, Request
 
4
 
5
  from app.config import settings
6
 
7
  router = APIRouter()
8
 
9
 
 
 
 
 
 
10
  @router.get("/health")
11
- async def health(request: Request) -> dict:
12
  return {
13
- "status": "ok",
14
- "whisper_loaded": getattr(request.app.state, "whisper_loaded", False),
15
- "groq_available": bool(settings.groq_api_key),
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 AuthSuccessMsg(BaseModel):
11
- type: Literal["auth_success"] = "auth_success"
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
- is_final: bool
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 ErrorMsg(BaseModel):
48
- type: Literal["error"] = "error"
49
- message: str
50
- code: str
51
 
 
 
 
 
 
 
 
 
 
52
 
53
- class PongMsg(BaseModel):
54
- type: Literal["pong"] = "pong"
55
 
 
 
 
 
56
 
57
- # ── Client → Server ───────────────────────────────────────────────────────────
58
 
59
- class AuthFrame(BaseModel):
60
- type: Literal["auth"]
61
- api_key: str
62
 
 
 
 
 
 
63
 
64
- class ControlFrame(BaseModel):
65
- type: Literal["start_session", "stop_session", "ping", "set_model"]
66
- model: str | None = None
 
 
 
 
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 re
7
- import time
8
- from dataclasses import dataclass
9
- from typing import TYPE_CHECKING, Literal
10
-
11
- if TYPE_CHECKING:
12
- from app.services.session_context import SessionContext
 
 
 
 
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
- _SYSTEM_PROMPT = """\
17
- You are a real-time meeting coach embedded in the participant's browser.
18
- Analyze the full meeting context and recent transcript, then generate ONE response.
19
-
20
- Rules:
21
- - If a question was asked but not answered → provide a direct, concise answer
22
- - If a claim needs challenging → suggest a clarifying question
23
- - If a decision or action item was implied → flag it explicitly
24
- - If a topic just resolved → offer a brief summary
25
- - If conversation stalled → suggest a re-engagement prompt
26
-
27
- Respond ONLY in this exact JSON format:
28
- {"category": "answer|question|action|summary|flag", "text": "...", "context": "one-line reason why this is relevant now"}
29
-
30
- Keep "text" under 20 words. Keep "context" under 10 words. Be specific and direct."""
31
-
32
- _SUMMARY_PROMPT = """\
33
- You are summarizing a business meeting.
34
-
35
- Current summary:
36
- {current_summary}
37
-
38
- New transcript segments:
39
- {new_segments}
40
-
41
- Write an updated summary under 300 words. Preserve all decisions, action items, and open questions. Use bullet points."""
42
-
43
- _MAX_RETRIES = 3
44
- _SUMMARY_TRIGGER = 10
45
- _SUGGESTION_DEBOUNCE_S = 2.0
46
- VALID_CATEGORIES = ("answer", "question", "action", "summary", "flag")
47
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
- @dataclass
50
- class SuggestionResult:
51
- text: str
52
- category: Literal["answer", "question", "action", "summary", "flag"] = "question"
53
- context: str = ""
54
 
55
 
56
  class GroqClient:
57
- MODEL = "llama-3.3-70b-versatile"
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
- if context is not None:
72
- self._context = context
73
- else:
74
- from app.services.session_context import SessionContext
75
- self._context = SessionContext()
76
-
77
- self._last_suggestion_time: float = 0.0
78
- self._background_tasks: set[asyncio.Task] = set()
79
-
80
- def add_transcription(self, text: str) -> None:
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
- response = await self._client.chat.completions.create(
113
- model=self.MODEL,
114
  messages=[
115
- {"role": "system", "content": _SYSTEM_PROMPT},
116
- {"role": "user", "content": user_prompt},
117
  ],
118
- temperature=0.7,
119
- max_tokens=120,
 
120
  )
121
- raw = response.choices[0].message.content or ""
122
- result = _parse_suggestion(raw)
123
- if result:
124
- logger.info("Groq suggestion [%s]: %r", result.category, result.text)
125
- return result
 
 
 
 
126
  except Exception as exc:
127
- should_retry = _is_retryable(exc)
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 None
131
- wait = 2 ** attempt
132
- logger.warning("Groq retry %d/%d in %ds", attempt + 1, _MAX_RETRIES, wait)
133
  await asyncio.sleep(wait)
134
 
135
- return None
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