import os import re import sqlite3 import uuid from datetime import datetime, timezone from pathlib import Path import httpx from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles DATA_DIR = Path(os.environ.get("DATA_DIR", "./data")) AUDIO_DIR = DATA_DIR / "audio" DB_PATH = DATA_DIR / "voice-kb.sqlite3" WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip() FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" DATA_DIR.mkdir(parents=True, exist_ok=True) AUDIO_DIR.mkdir(parents=True, exist_ok=True) app = FastAPI(title="Voice KB") def connect_db() -> sqlite3.Connection: connection = sqlite3.connect(DB_PATH) connection.row_factory = sqlite3.Row return connection def initialize_db() -> None: with connect_db() as connection: connection.execute( """CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL, filename TEXT NOT NULL, audio_path TEXT NOT NULL, transcript TEXT NOT NULL DEFAULT '', status TEXT NOT NULL )""" ) connection.execute( """CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(note_id UNINDEXED, transcript)""" ) initialize_db() def update_transcript(note_id: str, transcript: str, status: str) -> None: with connect_db() as connection: connection.execute( "UPDATE notes SET transcript = ?, status = ? WHERE id = ?", (transcript, status, note_id), ) connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,)) if transcript.strip(): connection.execute( "INSERT INTO note_search (note_id, transcript) VALUES (?, ?)", (note_id, transcript.strip()), ) async def transcribe_note(note_id: str, audio_path: Path) -> None: if not WHISPER_SERVER_URL: return try: async with httpx.AsyncClient(timeout=900) as client: with audio_path.open("rb") as audio_file: response = await client.post( WHISPER_SERVER_URL, files={"file": (audio_path.name, audio_file, "application/octet-stream")}, data={"response_format": "json"}, ) response.raise_for_status() transcript = response.json().get("text", "").strip() update_transcript(note_id, transcript, "transcribed" if transcript else "empty_transcript") except Exception: update_transcript(note_id, "", "transcription_failed") @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok"} @app.post("/api/notes", status_code=201) async def create_note( background_tasks: BackgroundTasks, recording_id: str = Form(...), audio: UploadFile = File(...), created_at: str = Form(...), ) -> dict[str, str]: try: note_id = str(uuid.UUID(recording_id)) except ValueError as error: raise HTTPException(status_code=400, detail="Invalid recording ID") from error with connect_db() as connection: existing = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone() if existing: return {"id": note_id, "status": existing["status"]} suffix = Path(audio.filename or "recording.webm").suffix.lower() if not suffix or len(suffix) > 10: suffix = ".audio" audio_path = AUDIO_DIR / f"{note_id}{suffix}" try: with audio_path.open("wb") as destination: while chunk := await audio.read(1024 * 1024): destination.write(chunk) except Exception as error: audio_path.unlink(missing_ok=True) raise HTTPException(status_code=500, detail="Could not store audio") from error finally: await audio.close() try: parsed_date = datetime.fromisoformat(created_at.replace("Z", "+00:00")) if parsed_date.tzinfo is None: parsed_date = parsed_date.replace(tzinfo=timezone.utc) created_at = parsed_date.astimezone(timezone.utc).isoformat() except ValueError: created_at = datetime.now(timezone.utc).isoformat() status = "awaiting_transcription" if not WHISPER_SERVER_URL else "transcribing" with connect_db() as connection: connection.execute( "INSERT OR IGNORE INTO notes (id, created_at, filename, audio_path, status) VALUES (?, ?, ?, ?, ?)", (note_id, created_at, audio.filename or "recording.audio", str(audio_path), status), ) if WHISPER_SERVER_URL: background_tasks.add_task(transcribe_note, note_id, audio_path) return {"id": note_id, "status": status} @app.get("/api/notes") def list_notes(q: str = "") -> list[dict[str, str]]: with connect_db() as connection: search_terms = re.findall(r"[\w]+", q, flags=re.UNICODE) if search_terms: query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms) rows = connection.execute( """SELECT n.id, n.created_at, n.filename, n.transcript, n.status FROM note_search s JOIN notes n ON n.id = s.note_id WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""", (query,), ).fetchall() else: rows = connection.execute( """SELECT id, created_at, filename, transcript, status FROM notes ORDER BY created_at DESC LIMIT 100""" ).fetchall() return [dict(row) for row in rows] @app.put("/api/notes/{note_id}/transcript") def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]: transcript = payload.get("transcript", "").strip() with connect_db() as connection: exists = connection.execute("SELECT 1 FROM notes WHERE id = ?", (note_id,)).fetchone() if not exists: raise HTTPException(status_code=404, detail="Note not found") update_transcript(note_id, transcript, "transcribed" if transcript else "awaiting_transcription") return {"id": note_id, "status": "transcribed" if transcript else "awaiting_transcription"} app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")