agent-tina / app.py
KPrashanth's picture
Deploy Agent Tina
fb59416 verified
Raw
History Blame Contribute Delete
10.7 kB
import os
import secrets
import tempfile
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from config import APP_BASE_URL, MAX_UPLOAD_MB, OPENROUTER_MODEL, TEMPLATES_DIR, WHISPER_MODEL
from llm import correct_transcript, generate_meeting_mom, generate_mom
from markdown import render_corrected_transcript, render_raw_transcript
from models import (
MeetingCreate,
MeetingOutputs,
RecordingMode,
Segment,
SegmentCreate,
SegmentStatus,
utc_now_iso,
)
from storage import (
add_segment,
create_meeting,
load_meeting,
require_host,
safe_download_path,
safe_recording_path,
save_meeting,
save_outputs,
save_segment_audio,
update_segment,
)
from transcription import inspect_audio, transcribe_audio
TEMPLATES = Jinja2Templates(directory=str(TEMPLATES_DIR))
app = FastAPI(title="Agent Tina")
def split_names(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def build_segment_label(payload: SegmentCreate) -> str:
if payload.recording_mode == RecordingMode.room_recorder:
return payload.room_label.strip() or payload.participant_name.strip() or "Room Recorder"
return payload.participant_name.strip() or "Unlabelled participant"
async def save_upload_to_temp(audio: UploadFile) -> str:
suffix = Path(audio.filename or "recording.webm").suffix or ".webm"
temp_path = None
bytes_written = 0
max_upload_bytes = MAX_UPLOAD_MB * 1024 * 1024
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = temp_file.name
while chunk := await audio.read(1024 * 1024):
bytes_written += len(chunk)
if bytes_written > max_upload_bytes:
raise HTTPException(
status_code=413,
detail=f"Recording exceeds the {MAX_UPLOAD_MB} MB upload limit.",
)
temp_file.write(chunk)
except Exception:
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
raise
finally:
await audio.close()
if bytes_written == 0:
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
raise HTTPException(status_code=400, detail="The uploaded recording is empty.")
return temp_path
def meeting_urls(meeting_id: str, host_key: str) -> dict[str, str]:
base = APP_BASE_URL.rstrip("/")
return {
"join_url": f"{base}/?meeting={meeting_id}",
"host_url": f"{base}/?meeting={meeting_id}&host={host_key}",
}
def meeting_response(meeting_id: str, host_key: str) -> dict[str, str]:
urls = meeting_urls(meeting_id, host_key)
return {"meeting_id": meeting_id, "host_key": host_key, **urls}
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
return TEMPLATES.TemplateResponse(
"index.html",
{
"request": request,
"model": OPENROUTER_MODEL,
"whisper_model": WHISPER_MODEL,
"max_upload_mb": MAX_UPLOAD_MB,
},
)
@app.post("/api/process")
async def process_audio(
title: str = Form(""),
context: str = Form(""),
audio: UploadFile = File(...),
) -> dict[str, str]:
temp_path = await save_upload_to_temp(audio)
try:
transcript = transcribe_audio(temp_path, initial_prompt=context)
corrected_transcript = transcript
minutes = generate_mom(title, context, corrected_transcript)
return {
"transcript": transcript,
"corrected_transcript": corrected_transcript,
"minutes": minutes,
}
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
@app.post("/api/meetings")
async def create_meeting_api(
title: str = Form(""),
meeting_type: str = Form("in_person"),
context: str = Form(""),
known_terms: str = Form(""),
) -> dict[str, object]:
meeting = create_meeting(
MeetingCreate(
title=title,
meeting_type=meeting_type,
context=context,
known_terms=known_terms,
)
)
return {"meeting": meeting.public_dict(), **meeting_response(meeting.meeting_id, meeting.host_key)}
@app.get("/api/meetings/{meeting_id}")
async def get_meeting_api(meeting_id: str) -> dict[str, object]:
meeting = load_meeting(meeting_id)
return {"meeting": meeting.public_dict()}
@app.get("/api/meetings/{meeting_id}/host")
async def get_host_meeting_api(meeting_id: str, host_key: str = Query("")) -> dict[str, object]:
meeting = load_meeting(meeting_id)
require_host(meeting, host_key)
warnings = build_meeting_warnings(meeting)
return {
"meeting": meeting.model_dump(),
"warnings": warnings,
**meeting_response(meeting.meeting_id, meeting.host_key),
}
@app.post("/api/meetings/{meeting_id}/segments")
async def create_segment_api(
meeting_id: str,
participant_name: str = Form(""),
recording_mode: str = Form("personal_mic"),
room_label: str = Form(""),
covered_participants: str = Form(""),
audio: UploadFile = File(...),
) -> dict[str, object]:
meeting = load_meeting(meeting_id)
payload = SegmentCreate(
participant_name=participant_name,
recording_mode=recording_mode,
room_label=room_label,
covered_participants=covered_participants,
)
if payload.recording_mode == RecordingMode.room_recorder:
if not payload.room_label.strip():
raise HTTPException(status_code=400, detail="Room label is required for Room Recorder.")
if not split_names(payload.covered_participants):
raise HTTPException(
status_code=400,
detail="List the people covered by this Room Recorder.",
)
elif not payload.participant_name.strip():
raise HTTPException(status_code=400, detail="Participant name is required for Personal Mic.")
segment = Segment(
segment_id=f"seg-{secrets.token_hex(6)}",
meeting_id=meeting.meeting_id,
label=build_segment_label(payload),
participant_name=payload.participant_name.strip(),
recording_mode=payload.recording_mode,
room_label=payload.room_label.strip(),
covered_participants=split_names(payload.covered_participants),
)
temp_path = await save_upload_to_temp(audio)
try:
saved_audio = save_segment_audio(
meeting.meeting_id,
segment.segment_id,
temp_path,
Path(audio.filename or "recording.webm").suffix or ".webm",
)
segment.audio_file_name = saved_audio.name
segment.audio_size_bytes = saved_audio.stat().st_size
audio_info = inspect_audio(temp_path)
segment.audio_duration_seconds = float(audio_info["duration_seconds"])
segment.audio_format = str(audio_info["format"])
segment.audio_channels = int(audio_info["channels"])
segment.audio_sample_rate = int(audio_info["sample_rate"])
meeting = add_segment(meeting, segment)
initial_prompt = "\n".join(
part
for part in [
meeting.title,
meeting.context,
meeting.known_terms,
segment.label,
", ".join(segment.covered_participants),
]
if part
)
segment.raw_transcript = transcribe_audio(temp_path, initial_prompt=initial_prompt)
segment.status = SegmentStatus.transcribed
meeting = update_segment(meeting.meeting_id, segment)
segment.corrected_transcript = correct_transcript(meeting, segment)
segment.status = SegmentStatus.corrected
meeting = update_segment(meeting.meeting_id, segment)
except Exception as exc:
segment.status = SegmentStatus.failed
segment.error = str(exc)
update_segment(meeting.meeting_id, segment)
raise
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return {"segment": segment.model_dump(), "meeting": meeting.public_dict()}
@app.post("/api/meetings/{meeting_id}/generate")
async def generate_meeting_outputs_api(
meeting_id: str,
host_key: str = Form(""),
) -> dict[str, object]:
meeting = load_meeting(meeting_id)
require_host(meeting, host_key)
raw_md = render_raw_transcript(meeting)
corrected_md = render_corrected_transcript(meeting)
minutes_md = generate_meeting_mom(meeting, corrected_md)
meeting.outputs = MeetingOutputs(
raw_transcript_md=raw_md,
corrected_transcript_md=corrected_md,
minutes_md=minutes_md,
generated_at=utc_now_iso(),
)
save_meeting(meeting)
save_outputs(meeting)
return {
"meeting": meeting.model_dump(),
"raw_transcript_md": raw_md,
"corrected_transcript_md": corrected_md,
"minutes_md": minutes_md,
}
@app.get("/api/meetings/{meeting_id}/downloads/{file_name}")
async def download_meeting_file_api(
meeting_id: str,
file_name: str,
host_key: str = Query(""),
) -> FileResponse:
meeting = load_meeting(meeting_id)
require_host(meeting, host_key)
path = safe_download_path(meeting_id, file_name)
media_type = "application/json" if file_name.endswith(".json") else "text/markdown"
return FileResponse(path, media_type=media_type, filename=file_name)
@app.get("/api/meetings/{meeting_id}/recordings/{file_name}")
async def download_meeting_recording_api(
meeting_id: str,
file_name: str,
host_key: str = Query(""),
) -> FileResponse:
meeting = load_meeting(meeting_id)
require_host(meeting, host_key)
path = safe_recording_path(meeting_id, file_name)
return FileResponse(path, media_type="application/octet-stream", filename=file_name)
def build_meeting_warnings(meeting) -> list[str]:
warnings = []
room_segments = [
segment for segment in meeting.segments if segment.recording_mode == RecordingMode.room_recorder
]
if meeting.meeting_type.value in {"in_person", "hybrid"} and not room_segments:
warnings.append("No Room Recorder segment has been submitted yet.")
if meeting.meeting_type.value == "in_person" and len(room_segments) > 1:
warnings.append("Multiple Room Recorder segments may duplicate speech unless they represent separate rooms.")
return warnings