RemiProAtos commited on
Commit
8fc59e8
·
verified ·
1 Parent(s): f846200

refactor as gradio server

Browse files

POST /gradio_api/call/text_turn — streaming text chat
POST /gradio_api/call/voice_turn — streaming voice chat (upload audio path)
POST /gradio_api/call/get_metrics — metrics snapshot
POST /gradio_api/call/reset_metrics — reset counters

Files changed (1) hide show
  1. app.py +100 -299
app.py CHANGED
@@ -31,13 +31,9 @@ import os
31
  import re
32
  import time
33
 
34
- import gradio as gr
35
  import httpx
36
- import numpy as np
37
  import soundfile as sf
38
- from pathlib import Path
39
- from mistralai.client import Mistral
40
-
41
 
42
  logging.basicConfig(
43
  level=logging.INFO,
@@ -72,22 +68,9 @@ TTS_API_URL = os.getenv(
72
  "https://api.mistral.ai/v1/audio/speech",
73
  )
74
  TTS_MODEL = os.getenv("TTS_MODEL", "voxtral-mini-tts-2603")
75
- TTS_VOICE = os.getenv("TTS_VOICE", "5a271406-039d-46fe-835b-fbbb00eaf08d") # "marie_neutral")
76
 
77
  # ── MLflow Tracing ──────────────────────────────────────────────────────
78
- # 1. Deploy a cloud MLflow 3 Tracking Server (e.g. on a VM or managed).
79
- # 2. Set MLFLOW_TRACKING_URI to its URL, e.g. "https://mlflow.example.com".
80
- # 3. Optionally set MLFLOW_EXPERIMENT_NAME (default: "mistral-chatbot").
81
- # 4. Both are read from environment variables (HF Space secrets).
82
- #
83
- # What gets traced automatically (via mlflow.mistral.autolog()):
84
- # - STT calls (go through the Mistral Python SDK)
85
- #
86
- # What gets traced manually (via mlflow.start_span):
87
- # - LLM streaming calls (Mistral autolog doesn't support streaming)
88
- #
89
- # What is NOT traced:
90
- # - TTS calls (raw httpx, low diagnostic value)
91
  MLFLOW_TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI", "")
92
  MLFLOW_EXPERIMENT_NAME = os.getenv("MLFLOW_EXPERIMENT_NAME", "mistral-chatbot")
93
 
@@ -99,40 +82,21 @@ SYSTEM_PROMPT = {
99
  }
100
 
101
  if not MISTRAL_API_KEY:
102
- print("⚠️ MISTRAL_API_KEY not set — all API calls will fail.")
103
 
104
 
105
  # ══════════════════════════════════════════════════════════════════════════
106
  # MLflow setup (runs once at module import time)
107
  # ══════════════════════════════════════════════════════════════════════════
108
 
109
- # Guard flag — tracing is only active when a tracking URI is configured.
110
  MLFLOW_ENABLED = bool(MLFLOW_TRACKING_URI)
111
 
112
  if MLFLOW_ENABLED:
113
- # The mlflow package is declared in requirements.txt; if it's somehow
114
- # missing the try/except degrades gracefully (tracing disabled).
115
  try:
116
  import mlflow
117
 
118
- # Point the MLflow client at your cloud server.
119
- # Example: mlflow.set_tracking_uri("https://mlflow.example.com")
120
  mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
121
-
122
- # Create or reuse the experiment. Runs will be grouped under it.
123
  mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME)
124
-
125
- # ── Auto-trace Mistral SDK calls ──────────────────────────────
126
- # This patches Mistral's Python SDK so every call to
127
- # client.chat.complete()
128
- # client.audio.transcriptions.complete()
129
- # etc.
130
- # is automatically recorded as a trace in MLflow.
131
- #
132
- # LIMITATION: streaming chat is NOT auto-traced; we handle that
133
- # manually in the stream_reply() function further down.
134
- #
135
- # Doc: https://mlflow.org/docs/latest/genai/tracing/integrations/listing/mistral/
136
  mlflow.mistral.autolog()
137
 
138
  logger.info(
@@ -148,11 +112,7 @@ if MLFLOW_ENABLED:
148
  )
149
  MLFLOW_ENABLED = False
150
 
151
- # ── Mistral SDK client (used for auto-traced calls) ─────────────────────
152
- # We instantiate the async Mistral client. Calls made through this client
153
- # (e.g. audio transcriptions) are auto-traced by mlflow.mistral.autolog().
154
- # The LLM streaming call uses raw httpx instead (see stream_llm) because
155
- # the Mistral SDK + streaming is not supported by MLflow auto-tracing.
156
  if MLFLOW_ENABLED:
157
  try:
158
  from mistralai.async_client import MistralAsync as _MistralAsync
@@ -165,11 +125,11 @@ else:
165
 
166
 
167
  # ══════════════════════════════════════════════════════════════════════════
168
- # IN-MEMORY METRICS (not related to MLflow — shown in the Gradio UI)
169
  # ══════════════════════════════════════════════════════════════════════════
170
 
171
  class Metrics:
172
- """Simple in-memory counters and accumulators for the UI stats panel."""
173
 
174
  def __init__(self):
175
  self.lock = asyncio.Lock()
@@ -209,75 +169,41 @@ class Metrics:
209
  if len(self.last_errors) > 20:
210
  self.last_errors.pop(0)
211
 
212
- def snapshot_md(self) -> str:
213
  def avg(total, count):
214
- return f"{total/count:.2f}s" if count else "—"
215
 
216
  m = self
217
- lines = [
218
- "### 📊 Metrics",
219
- "| Service | Calls | Avg Latency | Total Time |",
220
- "|---------|-------|-------------|------------|",
221
- f"| **STT** | {m.stt_count} | {avg(m.stt_total_s, m.stt_count)} | {m.stt_total_s:.1f}s |",
222
- f"| **LLM** | {m.llm_count} | {avg(m.llm_total_s, m.llm_count)} | {m.llm_total_s:.1f}s |",
223
- f"| **TTS** | {m.tts_count} | {avg(m.tts_total_s, m.tts_count)} | {m.tts_total_s:.1f}s |",
224
- "",
225
- f"**Total tokens generated:** {m.total_tokens}",
226
- f"**Errors:** {m.error_count}",
227
- ]
228
- if m.last_errors:
229
- lines.append("\n**Recent errors:**")
230
- for e in m.last_errors[-5:]:
231
- lines.append(f"- `{e[:120]}`")
232
- return "\n".join(lines)
233
 
234
 
235
  _metrics = Metrics()
236
 
237
 
238
- async def reset_metrics():
239
- _metrics.reset()
240
- return _metrics.snapshot_md()
241
-
242
-
243
  # ══════════════════════════════════════════════════════════════════════════
244
  # HELPERS
245
  # ══════════════════════════════════════════════════════════════════════════
246
 
247
- def to_chatbot(history: list) -> list[dict]:
248
- """Convert internal tuple history [(user, bot), …] Gradio messages format."""
249
- msgs = []
250
- for user_text, assistant_text in history:
251
- if user_text:
252
- msgs.append({"role": "user", "content": user_text})
253
- if assistant_text:
254
- msgs.append({"role": "assistant", "content": assistant_text})
255
- return msgs
256
-
257
-
258
- def build_messages(history: list, user_text: str) -> list[dict]:
259
- """Build the messages array for the Mistral Chat API from conversation history."""
260
- messages = [SYSTEM_PROMPT]
261
- for human, assistant in history:
262
- messages.append({"role": "user", "content": human})
263
- messages.append({"role": "assistant", "content": assistant})
264
  messages.append({"role": "user", "content": user_text})
265
  return messages
266
 
267
 
268
  # ══════════════════════════════════════════════════════════════════════════
269
- # STT — speech-to-text (Mistral Transcriptions API, auto-traced via SDK)
270
  # ══════════════════════════════════════════════════════════════════════════
271
 
272
  async def transcribe(audio_path: str) -> str:
273
- """Convert audio to 16 kHz mono WAV and transcribe via Mistral STT API.
274
-
275
- When MLflow tracing is enabled, this call goes through the Mistral
276
- Python SDK and is automatically captured by mlflow.mistral.autolog().
277
- """
278
-
279
- # ── Audio preprocessing ───────────────────────────────────────────
280
- # Browser mic output (WebM/OGG) must be converted to 16 kHz mono WAV.
281
  import subprocess
282
  import tempfile as tf
283
 
@@ -300,19 +226,14 @@ async def transcribe(audio_path: str) -> str:
300
  if wav_path and os.path.exists(wav_path):
301
  os.unlink(wav_path)
302
 
303
- # ── Transcribe ────────────────────────────────────────────────────
304
  try:
305
  if _mistral is not None:
306
- # Mistral SDK call — auto-traced by MLflow via autolog().
307
- # The trace captures: model, audio file info, response text,
308
- # and usage stats (tokens, audio seconds).
309
  result = await _mistral.audio.transcriptions.complete(
310
  model=STT_MODEL,
311
  file={"content": audio_bytes, "file_name": "audio.wav"},
312
  )
313
  text = result.text
314
  else:
315
- # Fallback: raw httpx (no tracing).
316
  headers = {"Authorization": f"Bearer {MISTRAL_API_KEY}"}
317
  async with httpx.AsyncClient(timeout=120.0) as client:
318
  resp = await client.post(
@@ -327,12 +248,7 @@ async def transcribe(audio_path: str) -> str:
327
  text = resp.json()["text"]
328
 
329
  await _metrics.record_stt(time.perf_counter() - t0)
330
- logger.info(
331
- "STT ok %.2fs %.0f bytes model=%s",
332
- time.perf_counter() - t0,
333
- len(audio_bytes),
334
- STT_MODEL,
335
- )
336
  return text
337
  except Exception as e:
338
  await _metrics.record_error(f"STT: {e}")
@@ -340,35 +256,29 @@ async def transcribe(audio_path: str) -> str:
340
 
341
 
342
  # ══════════════════════════════════════════════════════════════════════════
343
- # TTS — text-to-speech (Mistral Speech API, raw httpx, not traced)
344
  # ══════════════════════════════════════════════════════════════════════════
345
 
346
- async def call_tts(client: Mistral, text: str) -> tuple | None:
347
- """Synthesise speech via Mistral TTS API, return (sample_rate, numpy_array).
348
- Uses the official Mistral SDK (audio.speech.complete).
349
- Not MLflow-traced (low diagnostic value for TTS).
350
- """
351
  t0 = time.perf_counter()
352
  try:
353
- kwargs: dict = {
 
354
  "model": TTS_MODEL,
355
  "input": text,
356
- "response_format": "mp3",
357
  }
358
  if TTS_VOICE:
359
- kwargs["voice_id"] = TTS_VOICE
360
- # SDK is synchronous — run in a thread to avoid blocking the event loop
361
- loop = asyncio.get_running_loop()
362
- response = await loop.run_in_executor(
363
- None,
364
- lambda: client.audio.speech.complete(**kwargs),
365
- )
366
- audio_bytes = base64.b64decode(response.audio_data)
367
- audio_np, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32")
368
  elapsed = time.perf_counter() - t0
369
  await _metrics.record_tts(elapsed)
370
  logger.info("TTS ok %.2fs %d chars model=%s", elapsed, len(text), TTS_MODEL)
371
- return sr, audio_np
372
  except Exception as e:
373
  await _metrics.record_error(f"TTS: {e}")
374
  logger.warning("TTS failed (%.1fs): %s", time.perf_counter() - t0, e)
@@ -376,17 +286,11 @@ async def call_tts(client: Mistral, text: str) -> tuple | None:
376
 
377
 
378
  # ══════════════════════════════════════════════════════════════════════════
379
- # LLM — streaming text generation (Mistral Chat API via httpx SSE)
380
  # ══════════════════════════════════════════════════════════════════════════
381
 
382
  async def stream_llm(messages: list[dict]):
383
- """Stream tokens from Mistral Chat API via Server-Sent Events.
384
-
385
- Yields (token, cumulative_token_count).
386
- Uses raw httpx because MLflow's Mistral autolog does NOT support
387
- streaming chat completions (the auto-trace would miss the response).
388
- Instead, the caller (stream_reply) manually records an MLflow span.
389
- """
390
  headers = {
391
  "Authorization": f"Bearer {MISTRAL_API_KEY}",
392
  "Content-Type": "application/json",
@@ -405,9 +309,7 @@ async def stream_llm(messages: list[dict]):
405
 
406
  try:
407
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
408
- async with client.stream(
409
- "POST", LLM_API_URL, json=body, headers=headers,
410
- ) as resp:
411
  resp.raise_for_status()
412
  async for line in resp.aiter_lines():
413
  if line.startswith("data: "):
@@ -431,43 +333,29 @@ async def stream_llm(messages: list[dict]):
431
 
432
  elapsed = time.perf_counter() - t0
433
  await _metrics.record_llm(elapsed, token_count)
434
- logger.info(
435
- "LLM ok %.2fs %d tokens %.1f tok/s model=%s",
436
- elapsed, token_count,
437
- token_count / elapsed if elapsed else 0,
438
- LLM_MODEL,
439
- )
440
  except httpx.HTTPStatusError as e:
441
  body_text = await e.response.aread()
442
  detail = body_text.decode(errors="replace")[:300]
443
  elapsed = time.perf_counter() - t0
444
- await _metrics.record_error(
445
- f"LLM HTTP {e.response.status_code}: {detail[:80]}"
446
- )
447
  logger.error("LLM HTTP %s %.1fs %s", e.response.status_code, elapsed, detail)
448
- yield f"⚠️ LLM API error ({e.response.status_code}): {detail}", 0
449
  except Exception as e:
450
  elapsed = time.perf_counter() - t0
451
  await _metrics.record_error(f"LLM: {type(e).__name__}: {e}")
452
  logger.error("LLM error %.1fs %s", elapsed, e)
453
- yield f"⚠️ LLM error: {type(e).__name__}: {e}", 0
454
 
455
 
456
  # ══════════════════════════════════════════════════════════════════════════
457
- # STREAM ORCHESTRATOR — consumes LLM tokens, records MLflow trace, calls TTS
458
  # ══════════════════════════════════════════════════════════════════════════
459
 
460
  async def stream_reply(messages: list[dict]):
461
- """Consume LLM stream, record an MLflow trace, and synthesize audio.
462
-
463
- Yields (partial_reply, audio_tuple_or_None).
464
 
465
- MLflow tracing:
466
- - The Mistral auto-log does not cover streaming completions.
467
- - We manually create a span with mlflow.start_span() that records
468
- the full input messages and the aggregated output.
469
- - The span is finalized when the stream ends (the ``finally`` block).
470
- - If MLFLOW_TRACKING_URI is not set, no trace is recorded.
471
  """
472
  token_buffer = ""
473
  full_reply = ""
@@ -478,57 +366,42 @@ async def stream_reply(messages: list[dict]):
478
  try:
479
  async for token_or_error, token_count in stream_llm(messages):
480
 
481
- # ── Error in stream → stop & propagate ───────────────────
482
- if token_or_error.startswith("⚠️"):
483
  _trace_error = token_or_error
484
- yield token_or_error, None
485
  return
486
 
487
  _trace_token_count = token_count
488
  token_buffer += token_or_error
489
  full_reply += token_or_error
490
 
491
- # ── Sentence-boundary TTS ─────────────────────────────────
492
- # When a complete sentence has arrived, dispatch a TTS call
493
- # so the user hears audio before the full reply finishes.
494
  match = SENTENCE_END.search(token_buffer)
495
  if match:
496
  sentence = token_buffer[: match.end()].strip()
497
  token_buffer = token_buffer[match.end():]
498
  if sentence:
499
- async with Mistral(api_key=MISTRAL_API_KEY) as client:
500
- audio = await call_tts(client, sentence)
501
- if audio is not None:
502
- yield full_reply, audio
503
- continue
504
 
505
- yield full_reply, None
506
 
507
- # ── Remaining text after last sentence boundary ───────────────
508
  if token_buffer.strip():
509
- async with Mistral(api_key=MISTRAL_API_KEY) as client:
510
- audio = await call_tts(client, token_buffer.strip())
511
- if audio is not None:
512
- yield full_reply, audio
513
- return
514
-
515
- yield full_reply, None
516
 
517
  finally:
518
- # ── MLflow manual trace ───────────────────────────────────────
519
- # This runs when the generator is exhausted (async for finishes)
520
- # or when an exception propagates out.
521
- #
522
- # It creates a single span ("llm_chat_stream") that contains the
523
- # full request (messages + params) and the aggregated response.
524
  if MLFLOW_ENABLED and full_reply:
525
  _elapsed = time.perf_counter() - _trace_start
526
  try:
527
  import mlflow
528
  from mlflow.tracing.fluent import start_span
529
 
530
- # start_span outside of a trace context creates a new
531
- # root span (i.e. a new trace).
532
  with start_span("llm_chat_stream") as span:
533
  span.set_inputs({
534
  "model": LLM_MODEL,
@@ -540,149 +413,77 @@ async def stream_reply(messages: list[dict]):
540
  "response": full_reply,
541
  "token_count": _trace_token_count,
542
  "latency_seconds": round(_elapsed, 3),
543
- "tokens_per_second": round(
544
- _trace_token_count / _elapsed, 1
545
- ) if _elapsed > 0 else 0,
546
  })
547
  if _trace_error:
548
- span.set_status(
549
- mlflow.tracing.Status.ERROR,
550
- str(_trace_error),
551
- )
552
  except Exception as trace_err:
553
- # Don't crash the app if the MLflow server is unreachable.
554
  logger.warning("MLflow trace recording failed: %s", trace_err)
555
 
556
 
557
  # ══════════════════════════════════════════════════════════════════════════
558
- # GRADIO TURN HANDLERS
559
  # ══════════════════════════════════════════════════════════════════════════
560
 
561
- async def run_turn(user_text: str, history: list):
562
- """Shared generator: streams chatbot text updates and per-sentence audio."""
563
- history = history or []
564
- messages = build_messages(history, user_text)
565
- new_history = history + [(user_text, "▌")]
566
- audio_chunks: list[np.ndarray] = []
567
-
568
- audio_sr = None
569
- async for partial_reply, audio in stream_reply(messages):
570
- new_history[-1] = (user_text, partial_reply + "▌")
571
- if audio is not None:
572
- sr, arr = audio
573
- audio_sr = sr
574
- audio_chunks.append(arr)
575
- yield to_chatbot(new_history), new_history, None, None, gr.update() # . , ., audio, ., . remove partial stream
576
-
577
- final_text = new_history[-1][1].rstrip("▌").rstrip()
578
- new_history[-1] = (user_text, final_text)
579
 
580
- full_audio = (
581
- (audio_sr, np.concatenate(audio_chunks)) if audio_chunks else None
582
- )
583
- yield to_chatbot(new_history), new_history, None, full_audio, gr.update()
584
 
 
 
 
585
 
586
- async def text_turn(user_text: str, history: list):
 
 
587
  if not user_text.strip():
588
- yield to_chatbot(history or []), history or [], None, None, user_text, gr.update()
589
  return
590
 
591
- first = True
592
- async for h, s, a, la, _ in run_turn(user_text, history):
593
- yield h, s, a, la, ("" if first else gr.update()), gr.update()
594
- first = False
595
 
 
 
 
596
 
597
- async def voice_turn(audio_path: str, history: list):
598
- if audio_path is None:
599
- yield to_chatbot(history or []), history or [], None, None, gr.update()
 
 
 
600
  return
601
 
602
  try:
603
  user_text = await transcribe(audio_path)
604
  except Exception as e:
 
605
  logger.exception("STT error")
606
- err = f"⚠️ STT error: {type(e).__name__}: {e}"
607
- yield to_chatbot((history or []) + [("[voice]", err)]), history or [], None, None, gr.update()
608
  return
609
 
610
- async for h, s, a, la, _ in run_turn(user_text, history):
611
- yield h, s, a, la, gr.update()
 
612
 
613
 
614
- # ══════════════════════════════════════════════════════════════════════════
615
- # GRADIO UI
616
- # ══════════════════════════════════════════════════════════════════════════
 
617
 
618
- DESCRIPTION = f"""## Voice Chatbot
619
- _API LLM: `{LLM_MODEL}` · STT: `{STT_MODEL}` · TTS: `{TTS_MODEL}`_
620
- """
621
 
622
- with gr.Blocks(
623
- title="Voice Chatbot",
624
- theme=gr.themes.Soft(),
625
- css="footer {visibility: hidden}",
626
- ) as demo:
627
- gr.Markdown(DESCRIPTION)
628
-
629
- chatbot = gr.Chatbot(label="Conversation", height=380)
630
- state = gr.State([])
631
- last_audio = gr.State(None)
632
-
633
- with gr.Row():
634
- text_box = gr.Textbox(
635
- placeholder="Type a message and press Enter …",
636
- show_label=False,
637
- scale=5,
638
- )
639
- send_btn = gr.Button("Send", scale=1, variant="primary")
640
-
641
- with gr.Row():
642
- mic_input = gr.Audio(
643
- sources=["microphone"],
644
- type="filepath",
645
- label="Voice input",
646
- scale=5,
647
- )
648
- voice_btn = gr.Button("Submit voice", scale=1)
649
-
650
- audio_out = gr.Audio(label="Bot response (audio)") # knowed issue on huggingface to stream audio ==> implement from GradioSpace https://huggingface.co/spaces/gradio/stream_audio_out/blob/main/app.py , autoplay=True)
651
- replay_btn = gr.Button("🔁 Replay", size="sm")
652
-
653
- # ── Observability panel ───────────────────────────────────────────
654
- with gr.Accordion("📊 Stats & Observability", open=False):
655
- stats_md = gr.Markdown(_metrics.snapshot_md())
656
- with gr.Row():
657
- refresh_btn = gr.Button("🔄 Refresh", size="sm")
658
- reset_btn = gr.Button("🗑 Reset", size="sm")
659
-
660
- refresh_btn.click(fn=_metrics.snapshot_md, outputs=[stats_md])
661
- reset_btn.click(fn=reset_metrics, outputs=[stats_md])
662
-
663
- # ── Event wiring ──────────────────────────────────────────────────
664
- send_btn.click(
665
- text_turn,
666
- inputs=[text_box, state],
667
- outputs=[chatbot, state, audio_out, last_audio, text_box, stats_md],
668
- )
669
- text_box.submit(
670
- text_turn,
671
- inputs=[text_box, state],
672
- outputs=[chatbot, state, audio_out, last_audio, text_box, stats_md],
673
- )
674
- voice_btn.click(
675
- voice_turn,
676
- inputs=[mic_input, state],
677
- outputs=[chatbot, state, audio_out, last_audio, stats_md],
678
- )
679
- replay_btn.click(
680
- fn=lambda a: a,
681
- inputs=[last_audio],
682
- outputs=[audio_out],
683
- )
684
-
685
- demo.queue()
686
 
687
  if __name__ == "__main__":
688
- demo.launch(server_name="0.0.0.0")
 
31
  import re
32
  import time
33
 
34
+ from gradio import Server
35
  import httpx
 
36
  import soundfile as sf
 
 
 
37
 
38
  logging.basicConfig(
39
  level=logging.INFO,
 
68
  "https://api.mistral.ai/v1/audio/speech",
69
  )
70
  TTS_MODEL = os.getenv("TTS_MODEL", "voxtral-mini-tts-2603")
71
+ TTS_VOICE = os.getenv("TTS_VOICE", "")
72
 
73
  # ── MLflow Tracing ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  MLFLOW_TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI", "")
75
  MLFLOW_EXPERIMENT_NAME = os.getenv("MLFLOW_EXPERIMENT_NAME", "mistral-chatbot")
76
 
 
82
  }
83
 
84
  if not MISTRAL_API_KEY:
85
+ print("WARNING: MISTRAL_API_KEY not set — all API calls will fail.")
86
 
87
 
88
  # ══════════════════════════════════════════════════════════════════════════
89
  # MLflow setup (runs once at module import time)
90
  # ══════════════════════════════════════════════════════════════════════════
91
 
 
92
  MLFLOW_ENABLED = bool(MLFLOW_TRACKING_URI)
93
 
94
  if MLFLOW_ENABLED:
 
 
95
  try:
96
  import mlflow
97
 
 
 
98
  mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
 
 
99
  mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME)
 
 
 
 
 
 
 
 
 
 
 
 
100
  mlflow.mistral.autolog()
101
 
102
  logger.info(
 
112
  )
113
  MLFLOW_ENABLED = False
114
 
115
+ # ── Mistral SDK client (used for auto-traced STT calls) ─────────────────
 
 
 
 
116
  if MLFLOW_ENABLED:
117
  try:
118
  from mistralai.async_client import MistralAsync as _MistralAsync
 
125
 
126
 
127
  # ══════════════════════════════════════════════════════════════════════════
128
+ # IN-MEMORY METRICS
129
  # ══════════════════════════════════════════════════════════════════════════
130
 
131
  class Metrics:
132
+ """Simple in-memory counters and accumulators."""
133
 
134
  def __init__(self):
135
  self.lock = asyncio.Lock()
 
169
  if len(self.last_errors) > 20:
170
  self.last_errors.pop(0)
171
 
172
+ def snapshot(self) -> dict:
173
  def avg(total, count):
174
+ return round(total / count, 3) if count else None
175
 
176
  m = self
177
+ return {
178
+ "stt": {"calls": m.stt_count, "avg_latency_s": avg(m.stt_total_s, m.stt_count), "total_s": round(m.stt_total_s, 2)},
179
+ "llm": {"calls": m.llm_count, "avg_latency_s": avg(m.llm_total_s, m.llm_count), "total_s": round(m.llm_total_s, 2)},
180
+ "tts": {"calls": m.tts_count, "avg_latency_s": avg(m.tts_total_s, m.tts_count), "total_s": round(m.tts_total_s, 2)},
181
+ "total_tokens": m.total_tokens,
182
+ "errors": m.error_count,
183
+ "last_errors": m.last_errors[-5:],
184
+ }
 
 
 
 
 
 
 
 
185
 
186
 
187
  _metrics = Metrics()
188
 
189
 
 
 
 
 
 
190
  # ══════════════════════════════════════════════════════════════════════════
191
  # HELPERS
192
  # ══════════════════════════════════════════════════════════════════════════
193
 
194
+ def build_messages(history: list[dict], user_text: str) -> list[dict]:
195
+ """Build the messages array for the Mistral Chat API."""
196
+ messages = [SYSTEM_PROMPT] + list(history)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  messages.append({"role": "user", "content": user_text})
198
  return messages
199
 
200
 
201
  # ══════════════════════════════════════════════════════════════════════════
202
+ # STT — speech-to-text
203
  # ══════════════════════════════════════════════════════════════════════════
204
 
205
  async def transcribe(audio_path: str) -> str:
206
+ """Convert audio to 16 kHz mono WAV and transcribe via Mistral STT API."""
 
 
 
 
 
 
 
207
  import subprocess
208
  import tempfile as tf
209
 
 
226
  if wav_path and os.path.exists(wav_path):
227
  os.unlink(wav_path)
228
 
 
229
  try:
230
  if _mistral is not None:
 
 
 
231
  result = await _mistral.audio.transcriptions.complete(
232
  model=STT_MODEL,
233
  file={"content": audio_bytes, "file_name": "audio.wav"},
234
  )
235
  text = result.text
236
  else:
 
237
  headers = {"Authorization": f"Bearer {MISTRAL_API_KEY}"}
238
  async with httpx.AsyncClient(timeout=120.0) as client:
239
  resp = await client.post(
 
248
  text = resp.json()["text"]
249
 
250
  await _metrics.record_stt(time.perf_counter() - t0)
251
+ logger.info("STT ok %.2fs %.0f bytes model=%s", time.perf_counter() - t0, len(audio_bytes), STT_MODEL)
 
 
 
 
 
252
  return text
253
  except Exception as e:
254
  await _metrics.record_error(f"STT: {e}")
 
256
 
257
 
258
  # ══════════════════════════════════════════════════════════════════════════
259
+ # TTS — text-to-speech
260
  # ══════════════════════════════════════════════════════════════════════════
261
 
262
+ async def call_tts(client: httpx.AsyncClient, text: str) -> str | None:
263
+ """Synthesise speech via Mistral TTS API. Returns base64-encoded WAV string."""
 
 
 
264
  t0 = time.perf_counter()
265
  try:
266
+ headers = {"Authorization": f"Bearer {MISTRAL_API_KEY}"}
267
+ body: dict = {
268
  "model": TTS_MODEL,
269
  "input": text,
270
+ "response_format": "wav",
271
  }
272
  if TTS_VOICE:
273
+ body["voice_id"] = TTS_VOICE
274
+
275
+ resp = await client.post(TTS_API_URL, headers=headers, json=body, timeout=60.0)
276
+ resp.raise_for_status()
277
+ data = resp.json()
 
 
 
 
278
  elapsed = time.perf_counter() - t0
279
  await _metrics.record_tts(elapsed)
280
  logger.info("TTS ok %.2fs %d chars model=%s", elapsed, len(text), TTS_MODEL)
281
+ return data["audio_data"] # base64-encoded WAV
282
  except Exception as e:
283
  await _metrics.record_error(f"TTS: {e}")
284
  logger.warning("TTS failed (%.1fs): %s", time.perf_counter() - t0, e)
 
286
 
287
 
288
  # ══════════════════════════════════════════════════════════════════════════
289
+ # LLM — streaming text generation
290
  # ══════════════════════════════════════════════════════════════════════════
291
 
292
  async def stream_llm(messages: list[dict]):
293
+ """Stream tokens from Mistral Chat API via SSE. Yields (token, cumulative_count)."""
 
 
 
 
 
 
294
  headers = {
295
  "Authorization": f"Bearer {MISTRAL_API_KEY}",
296
  "Content-Type": "application/json",
 
309
 
310
  try:
311
  async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
312
+ async with client.stream("POST", LLM_API_URL, json=body, headers=headers) as resp:
 
 
313
  resp.raise_for_status()
314
  async for line in resp.aiter_lines():
315
  if line.startswith("data: "):
 
333
 
334
  elapsed = time.perf_counter() - t0
335
  await _metrics.record_llm(elapsed, token_count)
336
+ logger.info("LLM ok %.2fs %d tokens %.1f tok/s model=%s", elapsed, token_count, token_count / elapsed if elapsed else 0, LLM_MODEL)
 
 
 
 
 
337
  except httpx.HTTPStatusError as e:
338
  body_text = await e.response.aread()
339
  detail = body_text.decode(errors="replace")[:300]
340
  elapsed = time.perf_counter() - t0
341
+ await _metrics.record_error(f"LLM HTTP {e.response.status_code}: {detail[:80]}")
 
 
342
  logger.error("LLM HTTP %s %.1fs %s", e.response.status_code, elapsed, detail)
343
+ yield f"[ERROR] LLM API error ({e.response.status_code}): {detail}", 0
344
  except Exception as e:
345
  elapsed = time.perf_counter() - t0
346
  await _metrics.record_error(f"LLM: {type(e).__name__}: {e}")
347
  logger.error("LLM error %.1fs %s", elapsed, e)
348
+ yield f"[ERROR] LLM error: {type(e).__name__}: {e}", 0
349
 
350
 
351
  # ══════════════════════════════════════════════════════════════════════════
352
+ # STREAM ORCHESTRATOR
353
  # ══════════════════════════════════════════════════════════════════════════
354
 
355
  async def stream_reply(messages: list[dict]):
356
+ """Consume LLM stream, record MLflow trace, and synthesize audio.
 
 
357
 
358
+ Yields dicts: {"reply": str, "audio_b64": str|None, "done": bool}
 
 
 
 
 
359
  """
360
  token_buffer = ""
361
  full_reply = ""
 
366
  try:
367
  async for token_or_error, token_count in stream_llm(messages):
368
 
369
+ if token_or_error.startswith("[ERROR]"):
 
370
  _trace_error = token_or_error
371
+ yield {"reply": token_or_error, "audio_b64": None, "done": True}
372
  return
373
 
374
  _trace_token_count = token_count
375
  token_buffer += token_or_error
376
  full_reply += token_or_error
377
 
 
 
 
378
  match = SENTENCE_END.search(token_buffer)
379
  if match:
380
  sentence = token_buffer[: match.end()].strip()
381
  token_buffer = token_buffer[match.end():]
382
  if sentence:
383
+ async with httpx.AsyncClient() as client:
384
+ audio_b64 = await call_tts(client, sentence)
385
+ yield {"reply": full_reply, "audio_b64": audio_b64, "done": False}
386
+ continue
 
387
 
388
+ yield {"reply": full_reply, "audio_b64": None, "done": False}
389
 
390
+ # Flush remaining text
391
  if token_buffer.strip():
392
+ async with httpx.AsyncClient() as client:
393
+ audio_b64 = await call_tts(client, token_buffer.strip())
394
+ yield {"reply": full_reply, "audio_b64": audio_b64, "done": True}
395
+ else:
396
+ yield {"reply": full_reply, "audio_b64": None, "done": True}
 
 
397
 
398
  finally:
 
 
 
 
 
 
399
  if MLFLOW_ENABLED and full_reply:
400
  _elapsed = time.perf_counter() - _trace_start
401
  try:
402
  import mlflow
403
  from mlflow.tracing.fluent import start_span
404
 
 
 
405
  with start_span("llm_chat_stream") as span:
406
  span.set_inputs({
407
  "model": LLM_MODEL,
 
413
  "response": full_reply,
414
  "token_count": _trace_token_count,
415
  "latency_seconds": round(_elapsed, 3),
416
+ "tokens_per_second": round(_trace_token_count / _elapsed, 1) if _elapsed > 0 else 0,
 
 
417
  })
418
  if _trace_error:
419
+ span.set_status(mlflow.tracing.Status.ERROR, str(_trace_error))
 
 
 
420
  except Exception as trace_err:
 
421
  logger.warning("MLflow trace recording failed: %s", trace_err)
422
 
423
 
424
  # ══════════════════════════════════════════════════════════════════════════
425
+ # GRADIO SERVER
426
  # ══════════════════════════════════════════════════════════════════════════
427
 
428
+ app = Server(
429
+ title="Voice Chatbot",
430
+ description=f"API LLM: `{LLM_MODEL}` · STT: `{STT_MODEL}` · TTS: `{TTS_MODEL}`",
431
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
 
 
 
 
 
433
 
434
+ @app.api(name="text_turn")
435
+ async def text_turn(user_text: str, history: list[dict]) -> dict:
436
+ """Send a text message and stream back reply chunks with optional audio.
437
 
438
+ history: list of {"role": "user"|"assistant", "content": str}
439
+ Yields: {"reply": str, "audio_b64": str|None, "done": bool}
440
+ """
441
  if not user_text.strip():
442
+ yield {"reply": "", "audio_b64": None, "done": True}
443
  return
444
 
445
+ messages = build_messages(history, user_text)
446
+ async for chunk in stream_reply(messages):
447
+ yield chunk
448
+
449
 
450
+ @app.api(name="voice_turn")
451
+ async def voice_turn(audio_path: str, history: list[dict]) -> dict:
452
+ """Transcribe audio then stream back a reply.
453
 
454
+ audio_path: local file path to recorded audio.
455
+ history: list of {"role": "user"|"assistant", "content": str}
456
+ Yields: {"reply": str, "audio_b64": str|None, "done": bool, "user_text": str}
457
+ """
458
+ if not audio_path:
459
+ yield {"reply": "", "audio_b64": None, "done": True, "user_text": ""}
460
  return
461
 
462
  try:
463
  user_text = await transcribe(audio_path)
464
  except Exception as e:
465
+ err = f"[ERROR] STT error: {type(e).__name__}: {e}"
466
  logger.exception("STT error")
467
+ yield {"reply": err, "audio_b64": None, "done": True, "user_text": ""}
 
468
  return
469
 
470
+ messages = build_messages(history, user_text)
471
+ async for chunk in stream_reply(messages):
472
+ yield {**chunk, "user_text": user_text}
473
 
474
 
475
+ @app.api(name="get_metrics")
476
+ def get_metrics() -> dict:
477
+ """Return current in-memory metrics snapshot."""
478
+ return _metrics.snapshot()
479
 
 
 
 
480
 
481
+ @app.api(name="reset_metrics")
482
+ async def reset_metrics_api() -> dict:
483
+ """Reset all in-memory metrics counters."""
484
+ _metrics.reset()
485
+ return _metrics.snapshot()
486
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
 
488
  if __name__ == "__main__":
489
+ app.launch(server_name="0.0.0.0")