Spaces:
Runtime error
Runtime error
| 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} | |
| 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, | |
| }, | |
| ) | |
| 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) | |
| 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)} | |
| async def get_meeting_api(meeting_id: str) -> dict[str, object]: | |
| meeting = load_meeting(meeting_id) | |
| return {"meeting": meeting.public_dict()} | |
| 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), | |
| } | |
| 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()} | |
| 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, | |
| } | |
| 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) | |
| 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 | |