One2SkipAfew's picture
Upload 3 files
adc56be verified
Raw
History Blame Contribute Delete
19 kB
"""
relaxntakenotes.africa — Backend API
Speech-to-Text, AI Summarization, Translation, and TTS platform.
"""
import os
import asyncio
import hashlib
import logging
import tempfile
import base64
import urllib.parse
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Form, Header, HTTPException, Request, Depends, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, field_validator
from dotenv import load_dotenv
from supabase import create_client, Client
from deepgram import DeepgramClient, PrerecordedOptions
from huggingface_hub import InferenceClient
import edge_tts
import httpx
import requests
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-7s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("relaxntakenotes")
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
# Resolve .env from workspace root regardless of cwd
_main_dir = os.path.dirname(os.path.abspath(__file__))
_parent_env = os.path.join(os.path.dirname(_main_dir), ".env")
if os.path.exists(".env"):
load_dotenv(".env")
elif os.path.exists("../.env"):
load_dotenv("../.env")
elif os.path.exists(_parent_env):
load_dotenv(_parent_env)
else:
load_dotenv()
# Core credentials
DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY")
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
HF_TOKEN = os.getenv("HF_TOKEN")
# AI provider config — serverless by default, dedicated endpoint as optional fallback
AI_PROVIDER = os.getenv("AI_PROVIDER", "hf-inference") # e.g. "hf-inference", "novita", "together"
AI_MODEL = os.getenv("AI_MODEL", "meta-llama/Meta-Llama-3.1-8B-Instruct")
HF_ENDPOINT_URL = os.getenv("HF_ENDPOINT_URL", "") # Legacy fallback — leave blank for serverless
# Budget limits — tripled for production capacity
MONTHLY_LIMIT_MINUTES = int(os.getenv("MONTHLY_LIMIT_MINUTES", "10000"))
USER_MONTHLY_LIMIT_MINUTES = int(os.getenv("USER_MONTHLY_LIMIT_MINUTES", "180"))
MAX_RECORDING_DURATION_MINUTES = int(os.getenv("MAX_RECORDING_DURATION_MINUTES", "30"))
# Security
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024))) # 50 MB
ALLOWED_ORIGINS = [
o.strip()
for o in os.getenv(
"ALLOWED_ORIGINS",
"https://relaxntakenotes.africa,http://localhost:5173,http://localhost:5174,http://localhost:4173",
).split(",")
if o.strip()
]
# ---------------------------------------------------------------------------
# Client initialisation
# ---------------------------------------------------------------------------
supabase: Optional[Client] = None
if SUPABASE_URL and SUPABASE_KEY:
try:
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
logger.info("Supabase client initialised.")
except Exception as exc:
logger.error("Failed to initialise Supabase client: %s", exc)
deepgram_client: Optional[DeepgramClient] = None
if DEEPGRAM_API_KEY:
deepgram_client = DeepgramClient(DEEPGRAM_API_KEY)
logger.info("Deepgram client initialised.")
# HF Inference — prefer serverless providers; fall back to dedicated endpoint if configured
_custom_model_name_cache: Optional[str] = None
def _get_custom_endpoint_model_name(endpoint_url: str, token: Optional[str]) -> str:
"""Discover the model ID served by a dedicated HF Inference Endpoint."""
global _custom_model_name_cache
if _custom_model_name_cache:
return _custom_model_name_cache
try:
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
headers = {"Authorization": f"Bearer {token}"} if token else {}
resp = requests.get(models_url, headers=headers, timeout=10.0)
if resp.status_code == 200:
data = resp.json()
if "data" in data and len(data["data"]) > 0:
_custom_model_name_cache = data["data"][0]["id"]
return _custom_model_name_cache
except Exception as exc:
logger.warning("Could not discover model on endpoint, using default: %s", exc)
return "unsloth/Llama-3.1-8B-Instruct-bnb-4bit"
class _CustomInferenceClient(InferenceClient):
"""Patched client that resolves the real model name on dedicated endpoints."""
def post(self, *args, **kwargs):
json_data = kwargs.get("json")
if isinstance(json_data, dict) and json_data.get("model") == "tgi":
json_data["model"] = _get_custom_endpoint_model_name(self.model, self.token)
return super().post(*args, **kwargs)
if HF_ENDPOINT_URL:
# Legacy dedicated endpoint fallback
hf_client = _CustomInferenceClient(model=HF_ENDPOINT_URL, token=HF_TOKEN, timeout=300.0)
_hf_active_model: Optional[str] = None # model resolved by endpoint
logger.info("HF client: dedicated endpoint at %s", HF_ENDPOINT_URL)
else:
provider_arg = AI_PROVIDER if AI_PROVIDER and AI_PROVIDER.lower() != "auto" else None
hf_client = InferenceClient(provider=provider_arg, api_key=HF_TOKEN, timeout=120.0)
_hf_active_model = AI_MODEL
logger.info("HF client: serverless provider=%s model=%s", provider_arg or "auto", AI_MODEL)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(
title="relaxntakenotes.africa API",
description="Speech-to-Text and AI Note-Taking backend platform.",
version="1.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "127.0.0.1"
def get_user_hash(request: Request, x_user_uuid: Optional[str] = Header(None)) -> str:
"""Deterministic user fingerprint from IP + browser UUID."""
client_ip = _get_client_ip(request)
uuid_part = x_user_uuid or "anonymous"
return hashlib.sha256(f"{client_ip}-{uuid_part}".encode()).hexdigest()
def _start_of_month_iso() -> str:
now = datetime.now(timezone.utc)
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
def get_usage_stats(user_hash: str) -> tuple[int, int]:
"""Return (user_seconds, global_seconds) for the current month."""
if not supabase:
return 0, 0
start = _start_of_month_iso()
try:
user_resp = (
supabase.table("usage_logs")
.select("duration_seconds")
.eq("user_hash", user_hash)
.gte("created_at", start)
.execute()
)
user_secs = sum(r["duration_seconds"] for r in user_resp.data)
global_resp = (
supabase.table("usage_logs")
.select("duration_seconds")
.gte("created_at", start)
.execute()
)
global_secs = sum(r["duration_seconds"] for r in global_resp.data)
return user_secs, global_secs
except Exception as exc:
logger.warning("DB error fetching usage stats: %s", exc)
return 0, 0
async def _call_ai(system_prompt: str, user_prompt: str) -> str:
"""Run an AI chat completion via the configured provider. Returns result text."""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
response = await asyncio.to_thread(
hf_client.chat_completion,
model=_hf_active_model,
messages=messages,
max_tokens=2048,
temperature=0.3,
)
return response.choices[0].message.content
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class AIFeaturesRequest(BaseModel):
transcript: str
feature_type: str # "summary" | "insights" | "translation"
metadata: Optional[dict] = None
target_language: Optional[str] = "French"
@field_validator("feature_type")
@classmethod
def validate_feature_type(cls, v: str) -> str:
allowed = {"summary", "insights", "translation"}
if v not in allowed:
raise ValueError(f"feature_type must be one of {allowed}")
return v
class TTSRequest(BaseModel):
text: str
voice: Optional[str] = "en-US-JennyNeural"
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/")
def read_root():
return {
"status": "online",
"service": "relaxntakenotes.africa API",
"version": "1.1.0",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@app.get("/health")
def health_check():
"""Container orchestration healthcheck."""
return {"status": "healthy"}
@app.get("/api/status")
async def get_status(request: Request, user_hash: str = Depends(get_user_hash)):
user_seconds, global_seconds = await asyncio.to_thread(get_usage_stats, user_hash)
user_min = user_seconds / 60.0
global_min = global_seconds / 60.0
return {
"global_usage_minutes": round(global_min, 2),
"global_limit_minutes": MONTHLY_LIMIT_MINUTES,
"user_usage_minutes": round(user_min, 2),
"user_limit_minutes": USER_MONTHLY_LIMIT_MINUTES,
"is_over_budget": global_min >= MONTHLY_LIMIT_MINUTES,
"user_is_over_limit": user_min >= USER_MONTHLY_LIMIT_MINUTES,
"max_recording_duration_minutes": MAX_RECORDING_DURATION_MINUTES,
}
@app.post("/api/transcribe")
async def transcribe_audio(
request: Request,
file: UploadFile = File(...),
user_hash: str = Depends(get_user_hash),
):
# Budget enforcement
if supabase:
user_secs, global_secs = await asyncio.to_thread(get_usage_stats, user_hash)
if (global_secs / 60.0) >= MONTHLY_LIMIT_MINUTES:
raise HTTPException(
status_code=403,
detail="Global platform transcription budget exceeded for this month.",
)
if (user_secs / 60.0) >= USER_MONTHLY_LIMIT_MINUTES:
raise HTTPException(
status_code=403,
detail="Personal monthly transcription limit reached. Upgrade for unlimited hours.",
)
try:
file_bytes = await file.read()
if len(file_bytes) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"File too large. Maximum allowed size is {MAX_UPLOAD_BYTES // (1024*1024)} MB.",
)
logger.info(
"Transcribe: file=%s type=%s size=%d bytes",
file.filename,
file.content_type,
len(file_bytes),
)
if not deepgram_client:
raise HTTPException(status_code=500, detail="Transcription service not configured.")
options = PrerecordedOptions(
model="nova-2",
smart_format=True,
diarize=True,
punctuate=True,
)
payload = {"buffer": file_bytes}
response = await asyncio.to_thread(
deepgram_client.listen.prerecorded.v("1").transcribe_file,
payload,
options,
timeout=httpx.Timeout(1800.0, connect=60.0),
)
response_dict = response.to_dict() if hasattr(response, "to_dict") else response
meta = response_dict.get("metadata", {})
duration_seconds = round(meta.get("duration", 0))
if duration_seconds > MAX_RECORDING_DURATION_MINUTES * 60:
raise HTTPException(
status_code=400,
detail=f"Audio exceeds {MAX_RECORDING_DURATION_MINUTES}-minute limit.",
)
# Parse diarized output
channels = response_dict.get("results", {}).get("channels", [])
transcript_text = ""
paragraphs: list[dict] = []
if channels:
alts = channels[0].get("alternatives", [])
if alts:
paras_data = alts[0].get("paragraphs", {}).get("paragraphs", [])
if paras_data:
for p in paras_data:
speaker = p.get("speaker", 0)
text = " ".join(s.get("text", "") for s in p.get("sentences", []))
paragraphs.append({"speaker": f"Speaker {speaker}", "text": text})
else:
transcript_text = alts[0].get("transcript", "")
paragraphs.append({"speaker": "Speaker 0", "text": transcript_text})
# Log usage
if supabase:
try:
await asyncio.to_thread(
lambda: supabase.table("usage_logs")
.insert({"user_hash": user_hash, "duration_seconds": duration_seconds})
.execute()
)
except Exception as db_err:
logger.error("Failed to log usage: %s", db_err)
return {
"duration_seconds": duration_seconds,
"paragraphs": paragraphs or [{"speaker": "Speaker 0", "text": transcript_text}],
"raw_transcript": transcript_text or " ".join(p["text"] for p in paragraphs),
}
except HTTPException:
raise
except Exception as exc:
logger.exception("Transcription error")
raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}")
@app.post("/api/ai-features")
async def generate_ai_features(payload: AIFeaturesRequest):
if not payload.transcript.strip():
raise HTTPException(status_code=400, detail="Transcript is empty.")
# Build prompt
user_prompt = f"Transcript:\n{payload.transcript}\n\n"
if payload.feature_type == "summary":
system_prompt = (
"You are an expert AI note-taking and note-synthesizing assistant. "
"Generate a highly structured summary of the provided transcript. "
"Include a concise executive summary, followed by formal meeting minutes "
"with timestamp references (if applicable), and list the main topics discussed. "
"Use bullet points and clean markdown formatting."
)
if payload.metadata:
meta_str = "\n".join(f"{k}: {v}" for k, v in payload.metadata.items() if v)
system_prompt += f"\nUse this metadata context for the document:\n{meta_str}"
elif payload.feature_type == "insights":
system_prompt = (
"You are a strategic analyst. "
"Analyze the following transcript and extract the key takeaways, "
"critical discussion points, specific actionable items (with assigned owners if mentioned), "
"and core themes. Format the response beautifully using markdown."
)
elif payload.feature_type == "translation":
target_lang = payload.target_language or "French"
system_prompt = (
f"You are a professional translator. Translate the following transcript accurately into {target_lang}. "
"Maintain the tone, speaker formatting (e.g., 'Speaker 0:', 'Speaker 1:'), and layout of the original text. "
"Return only the translated text."
)
else:
raise HTTPException(status_code=400, detail="Invalid feature_type.")
try:
result_text = await _call_ai(system_prompt, user_prompt)
return {"result": result_text}
except Exception as exc:
logger.exception("AI inference error")
raise HTTPException(
status_code=503,
detail="AI service is temporarily unavailable. Please try again shortly.",
)
@app.post("/api/tts")
async def text_to_speech(payload: TTSRequest):
if not payload.text.strip():
raise HTTPException(status_code=400, detail="Text payload is empty.")
fd, temp_path = tempfile.mkstemp(suffix=".mp3")
os.close(fd)
try:
communicate = edge_tts.Communicate(payload.text, payload.voice)
await communicate.save(temp_path)
return FileResponse(
path=temp_path,
media_type="audio/mpeg",
filename="voice_notes.mp3",
background=None, # ensures cleanup after send
)
except Exception as exc:
logger.warning("TTS generation failed, returning silent fallback: %s", exc)
try:
silent_b64 = (
"SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA"
"//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDA"
"wMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6u"
"rq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////"
"////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90"
"hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAM"
"AAAGkAAAAAAAAA0gAAAAATEFN//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MU"
"ZAkAAAGkAAAAAAAAA0gAAAAANVVV"
)
with open(temp_path, "wb") as f:
f.write(base64.b64decode(silent_b64))
return FileResponse(path=temp_path, media_type="audio/mpeg", filename="voice_notes.mp3")
except Exception as fb_err:
logger.error("TTS fallback also failed: %s", fb_err)
raise HTTPException(status_code=500, detail=f"TTS generation failed: {exc}")
@app.post("/api/download")
async def download_file(
content: str = Form(...),
filename: str = Form(...),
mime_type: str = Form(...),
is_base64: str = Form("false"),
):
try:
if is_base64 == "true":
if "," in content:
content = content.split(",", 1)[1]
file_bytes = base64.b64decode(content)
else:
file_bytes = content.encode("utf-8")
safe_filename = urllib.parse.quote(filename)
return Response(
content=file_bytes,
media_type=mime_type,
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{safe_filename}"},
)
except Exception as exc:
logger.error("Download error: %s", exc)
raise HTTPException(status_code=500, detail=f"Download failed: {exc}")