|
|
""" |
|
|
Meeting Minutes Generator - Backend API |
|
|
Handles audio transcription and minutes generation using Groq |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, File, UploadFile, HTTPException |
|
|
from pydantic import BaseModel |
|
|
from typing import Optional |
|
|
from groq import Groq |
|
|
import os |
|
|
from dotenv import load_dotenv |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = FastAPI( |
|
|
title="Meeting Minutes API", |
|
|
version="2.0.0", |
|
|
description="Transcribe meeting audio and generate formatted minutes using Groq" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GROQ_API_KEY = os.environ.get("GROQ_API_KEY") |
|
|
|
|
|
|
|
|
if not GROQ_API_KEY: |
|
|
raise ValueError("β GROQ_API_KEY not found in environment. Check your .env file!") |
|
|
|
|
|
|
|
|
groq_client = Groq( |
|
|
api_key=GROQ_API_KEY, |
|
|
max_retries=2, |
|
|
timeout=120.0 |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TranscribeResponse(BaseModel): |
|
|
""" |
|
|
Response model for successful transcription |
|
|
""" |
|
|
transcript: str |
|
|
file_size_mb: float |
|
|
filename: str |
|
|
success: bool |
|
|
|
|
|
class GenerateMinutesRequest(BaseModel): |
|
|
""" |
|
|
Request model for generating minutes |
|
|
""" |
|
|
transcript: str |
|
|
|
|
|
class GenerateMinutesResponse(BaseModel): |
|
|
""" |
|
|
Response model for generated minutes |
|
|
""" |
|
|
minutes: str |
|
|
success: bool |
|
|
|
|
|
class ErrorResponse(BaseModel): |
|
|
""" |
|
|
Response model for errors |
|
|
""" |
|
|
error: str |
|
|
detail: Optional[str] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MAX_FILE_SIZE_MB = 25 |
|
|
WHISPER_MODEL = "whisper-large-v3" |
|
|
WHISPER_TEMPERATURE = 0.1 |
|
|
|
|
|
|
|
|
LLM_MODEL = "openai/gpt-oss-120b" |
|
|
LLM_TEMPERATURE = 0.1 |
|
|
MAX_COMPLETION_TOKENS = 1024 |
|
|
|
|
|
|
|
|
MINUTES_SYSTEM_PROMPT = """You are an assistant that converts meeting transcripts into concise, factual minutes. |
|
|
|
|
|
Your task: |
|
|
1. Remove filler words and disfluencies (uh, um, like, you know). |
|
|
2. Restore punctuation and sentence boundaries. |
|
|
3. Extract clear, factual minutes. |
|
|
4. Do NOT invent facts. If unclear, mark [unclear]. |
|
|
5. Return the final output strictly in Markdown format with headings, bullets, and bold labels. |
|
|
|
|
|
Use this exact structure: |
|
|
|
|
|
## **Minutes of the Meeting** |
|
|
**- Date:** [if present, else "Unknown"] |
|
|
**- Attendees:** [if not mentioned, say "Unknown"; if only able to recognise a few, write their names and say "and others"] |
|
|
|
|
|
### **Summary** |
|
|
[2β3 sentences summarizing purpose and tone] |
|
|
|
|
|
### **Key Agenda and Discussions** |
|
|
1. ... |
|
|
2. ... |
|
|
|
|
|
### **Action Items** |
|
|
1. ... |
|
|
2. ... |
|
|
|
|
|
### **Open Issues / Concerns** |
|
|
1. ... |
|
|
2. ... |
|
|
|
|
|
### **Notes** [minimum 3 sentences] |
|
|
- Short factual notes or clarifications. |
|
|
|
|
|
Be concise, professional, and factually grounded. Maintain Markdown formatting faithfully.""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_file_size(file_bytes: bytes) -> tuple[bool, float]: |
|
|
""" |
|
|
Check if uploaded file is within size limit |
|
|
|
|
|
Args: |
|
|
file_bytes: Raw file bytes |
|
|
|
|
|
Returns: |
|
|
tuple: (is_valid, size_in_mb) |
|
|
- is_valid: True if file is under limit |
|
|
- size_in_mb: Actual file size in megabytes |
|
|
""" |
|
|
size_mb = len(file_bytes) / (1024 * 1024) |
|
|
is_valid = size_mb <= MAX_FILE_SIZE_MB |
|
|
return is_valid, size_mb |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/") |
|
|
def root(): |
|
|
""" |
|
|
Health check endpoint |
|
|
|
|
|
Returns API status and version info |
|
|
Used to verify backend is running correctly |
|
|
""" |
|
|
return { |
|
|
"message": "ποΈ Meeting Minutes API is running!", |
|
|
"version": "2.0.0", |
|
|
"status": "healthy", |
|
|
"endpoints": { |
|
|
"transcribe": "/transcribe (POST)", |
|
|
"generate_minutes": "/generate-minutes (POST)", |
|
|
"health": "/ (GET)" |
|
|
} |
|
|
} |
|
|
|
|
|
@app.post("/transcribe", response_model=TranscribeResponse) |
|
|
async def transcribe_audio(file: UploadFile = File(...)): |
|
|
""" |
|
|
Transcribe audio file to text using Groq Whisper Large v3 |
|
|
|
|
|
FLOW: |
|
|
1. Receive audio file from client (Gradio UI) |
|
|
2. Read file bytes into memory |
|
|
3. Validate file size (must be < 25MB) |
|
|
4. Send file to Groq Whisper API |
|
|
5. Receive transcript text |
|
|
6. Validate transcript is not empty |
|
|
7. Return transcript with metadata |
|
|
|
|
|
Args: |
|
|
file: Uploaded audio file |
|
|
Supported formats: mp3, wav, m4a, webm, flac |
|
|
|
|
|
Returns: |
|
|
TranscribeResponse: Contains transcript text and metadata |
|
|
|
|
|
Raises: |
|
|
HTTPException 400: File too large or invalid |
|
|
HTTPException 500: Groq API error |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
file_bytes = await file.read() |
|
|
except Exception as e: |
|
|
raise HTTPException( |
|
|
status_code=400, |
|
|
detail=f"Failed to read uploaded file: {str(e)}" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
is_valid_size, size_mb = check_file_size(file_bytes) |
|
|
|
|
|
if not is_valid_size: |
|
|
raise HTTPException( |
|
|
status_code=400, |
|
|
detail=f"File too large ({size_mb:.2f}MB). Maximum allowed is {MAX_FILE_SIZE_MB}MB. " |
|
|
f"Please upload a shorter recording or compress the audio." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
transcription = groq_client.audio.transcriptions.create( |
|
|
file=(file.filename, file_bytes), |
|
|
model=WHISPER_MODEL, |
|
|
temperature=WHISPER_TEMPERATURE, |
|
|
response_format="text" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
transcript_text = transcription |
|
|
|
|
|
except Exception as e: |
|
|
|
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail=f"Transcription failed: {str(e)}. Please try again." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not transcript_text or len(transcript_text.strip()) == 0: |
|
|
raise HTTPException( |
|
|
status_code=400, |
|
|
detail="No speech detected in audio file. Please ensure the recording contains clear speech." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return TranscribeResponse( |
|
|
transcript=transcript_text.strip(), |
|
|
file_size_mb=round(size_mb, 2), |
|
|
filename=file.filename, |
|
|
success=True |
|
|
) |
|
|
|
|
|
@app.post("/generate-minutes", response_model=GenerateMinutesResponse) |
|
|
async def generate_minutes(request: GenerateMinutesRequest): |
|
|
""" |
|
|
Generate formatted meeting minutes from raw transcript using Groq LLM |
|
|
|
|
|
FLOW: |
|
|
1. Receive raw transcript text |
|
|
2. Validate transcript is not empty |
|
|
3. Build messages array (system prompt + user transcript) |
|
|
4. Call Groq LLM (gpt-oss-120b) |
|
|
5. Receive formatted Markdown minutes |
|
|
6. Validate output is not empty |
|
|
7. Return formatted minutes |
|
|
|
|
|
Args: |
|
|
request: GenerateMinutesRequest containing transcript text |
|
|
|
|
|
Returns: |
|
|
GenerateMinutesResponse: Contains formatted Markdown minutes |
|
|
|
|
|
Raises: |
|
|
HTTPException 400: Empty transcript |
|
|
HTTPException 500: Groq API error |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not request.transcript or len(request.transcript.strip()) == 0: |
|
|
raise HTTPException( |
|
|
status_code=400, |
|
|
detail="Transcript cannot be empty. Please provide a valid transcript." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
messages = [ |
|
|
{ |
|
|
"role": "system", |
|
|
"content": MINUTES_SYSTEM_PROMPT |
|
|
}, |
|
|
{ |
|
|
"role": "user", |
|
|
"content": f"Please convert the following meeting transcript into structured minutes:\n\n{request.transcript}" |
|
|
} |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
completion = groq_client.chat.completions.create( |
|
|
model=LLM_MODEL, |
|
|
messages=messages, |
|
|
temperature=LLM_TEMPERATURE, |
|
|
max_completion_tokens=MAX_COMPLETION_TOKENS, |
|
|
top_p=1, |
|
|
stream=False, |
|
|
stop=None |
|
|
) |
|
|
|
|
|
|
|
|
minutes_text = completion.choices[0].message.content |
|
|
|
|
|
except Exception as e: |
|
|
|
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail=f"Minutes generation failed: {str(e)}. Please try again." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not minutes_text or len(minutes_text.strip()) == 0: |
|
|
raise HTTPException( |
|
|
status_code=500, |
|
|
detail="LLM returned empty response. Please try again." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return GenerateMinutesResponse( |
|
|
minutes=minutes_text.strip(), |
|
|
success=True |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
import uvicorn |
|
|
|
|
|
print("π Starting Meeting Minutes Backend...") |
|
|
print("π Server will run on: http://localhost:8000") |
|
|
print("π API docs available at: http://localhost:8000/docs") |
|
|
print("π Health check: http://localhost:8000") |
|
|
print("\nβ
Available endpoints:") |
|
|
print(" POST /transcribe - Convert audio to text") |
|
|
print(" POST /generate-minutes - Convert transcript to formatted minutes") |
|
|
|
|
|
|
|
|
uvicorn.run( |
|
|
app, |
|
|
host="0.0.0.0", |
|
|
port=8001, |
|
|
log_level="info" |
|
|
) |