197 lines
7.7 KiB
Python
197 lines
7.7 KiB
Python
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 / "faerro-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="Faerro 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:
|
|
result = 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 result.rowcount and 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 = "", status: 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)
|
|
if status:
|
|
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 ? AND n.status = ?
|
|
ORDER BY n.created_at DESC LIMIT 100""",
|
|
(query, status),
|
|
).fetchall()
|
|
else:
|
|
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()
|
|
elif status:
|
|
rows = connection.execute(
|
|
"""SELECT id, created_at, filename, transcript, status
|
|
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
|
|
(status,),
|
|
).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.delete("/api/notes/{note_id}")
|
|
def delete_note(note_id: str) -> dict[str, str]:
|
|
with connect_db() as connection:
|
|
note = connection.execute(
|
|
"SELECT audio_path FROM notes WHERE id = ?", (note_id,)
|
|
).fetchone()
|
|
if not note:
|
|
raise HTTPException(status_code=404, detail="Note not found")
|
|
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
|
connection.execute("DELETE FROM notes WHERE id = ?", (note_id,))
|
|
Path(note["audio_path"]).unlink(missing_ok=True)
|
|
return {"id": note_id, "status": "deleted"}
|
|
|
|
|
|
@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") |