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