feat: API router + pipeline — toggle, status, transcripts, WebSocket, auth stub

This commit is contained in:
2026-04-01 02:26:47 +02:00
parent ad84ad5aa3
commit 319db8c788
3 changed files with 211 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import os
import tempfile
from api.state import state, Status
from config import load as load_config
from transcription import engine as transcription_engine
from llm import OllamaClient
from output import save_transcript
from api.router import broadcast
async def run_pipeline():
cfg = load_config()
recorder = getattr(state, "_recorder", None)
if recorder is None:
return
output_dir = getattr(state, "_recording_output_dir", cfg["output"]["path"])
instructions = getattr(state, "_recording_instructions", "")
recorder.stop()
await state.set_status(Status.PROCESSING)
await broadcast({"event": "processing"})
wav_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
wav_path = f.name
recorder.save_wav(wav_path)
raw_text = await transcription_engine.transcribe_file(
wav_path,
language=cfg["whisper"]["language"],
model_name=cfg["whisper"]["model"],
device=cfg["whisper"]["device"],
)
await broadcast({"event": "transcribed", "raw": raw_text})
client = OllamaClient(base_url=cfg["ollama"]["base_url"])
refined = await client.refine(
raw_text=raw_text,
instructions=instructions,
model=cfg["ollama"]["model"],
)
await broadcast({"event": "refined", "markdown": refined})
title = "Diktat"
for line in refined.splitlines():
if line.startswith("# "):
title = line[2:].strip()
break
path = save_transcript(
title=title,
content=refined,
output_dir=output_dir,
)
await broadcast({"event": "saved", "path": path, "title": title})
await state.set_status(Status.IDLE)
except Exception as e:
state.last_error = str(e)
await state.set_status(Status.ERROR)
await broadcast({"event": "error", "message": str(e)})
finally:
state.recording_user = None
state._recording_output_dir = None
state._recording_instructions = ""
if wav_path:
try:
os.unlink(wav_path)
except OSError:
pass
+108
View File
@@ -0,0 +1,108 @@
import asyncio
import os
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from api.state import state, Status
from config import load as load_config
from output import list_transcripts
router = APIRouter()
_ws_clients: list[WebSocket] = []
# ---------------------------------------------------------------------------
# Auth stub — replaced by auth.py in Task 13.
# current_user() returns a dict with at least {"username": str, "output_dir": str}.
# Until auth is wired up, every request runs as an anonymous guest using the
# global output path from config.
# ---------------------------------------------------------------------------
def _guest_user() -> dict:
cfg = load_config()
return {"username": "guest", "output_dir": cfg["output"]["path"]}
def current_user(user: dict = Depends(_guest_user)) -> dict:
return user
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/status")
async def get_status():
return {"status": state.status}
@router.post("/toggle")
async def toggle_recording(user: dict = Depends(current_user)):
from api.pipeline import run_pipeline
if state.status == Status.RECORDING:
asyncio.create_task(run_pipeline())
return {"action": "stopped"}
if state.status == Status.IDLE:
from audio import AudioRecorder
state._recorder = AudioRecorder()
state._recorder.start()
state.recording_user = user["username"]
state._recording_output_dir = user["output_dir"]
state._recording_instructions = user.get("instructions", "")
await state.set_status(Status.RECORDING)
return {"action": "started"}
return {"action": "busy", "status": state.status}
@router.post("/instructions")
async def set_instructions(body: dict, user: dict = Depends(current_user)):
# Instructions are per-user; stored on state only for the active recording.
# Full per-user persistence comes in Task 13.
user["instructions"] = body.get("instructions", "")
return {"ok": True}
@router.get("/transcripts")
async def get_transcripts(user: dict = Depends(current_user)):
return list_transcripts(user["output_dir"])
@router.get("/config")
async def get_config():
return load_config()
@router.put("/config")
async def put_config(body: dict):
cfg = load_config()
cfg.update(body)
return cfg
@router.post("/open")
async def open_file(body: dict):
import subprocess
path = body.get("path", "")
if path and os.path.exists(path):
subprocess.Popen(["xdg-open", path])
return {"ok": True}
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
_ws_clients.append(ws)
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
if ws in _ws_clients:
_ws_clients.remove(ws)
async def broadcast(message: dict):
for ws in list(_ws_clients):
try:
await ws.send_json(message)
except Exception:
if ws in _ws_clients:
_ws_clients.remove(ws)
+30
View File
@@ -0,0 +1,30 @@
from fastapi.testclient import TestClient
def make_app():
from fastapi import FastAPI
from api.router import router
app = FastAPI()
app.include_router(router)
return app
def test_status_returns_idle():
client = TestClient(make_app())
r = client.get("/status")
assert r.status_code == 200
assert r.json()["status"] == "idle"
def test_config_get_returns_dict():
client = TestClient(make_app())
r = client.get("/config")
assert r.status_code == 200
assert "ollama" in r.json()
def test_transcripts_returns_list():
client = TestClient(make_app())
r = client.get("/transcripts")
assert r.status_code == 200
assert isinstance(r.json(), list)