From 319db8c788dbde0221c6236458ec49aa70e900af Mon Sep 17 00:00:00 2001 From: "thomas.kopp" Date: Wed, 1 Apr 2026 02:26:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20API=20router=20+=20pipeline=20=E2=80=94?= =?UTF-8?q?=20toggle,=20status,=20transcripts,=20WebSocket,=20auth=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/pipeline.py | 73 +++++++++++++++++++++++++++++++ api/router.py | 108 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_api.py | 30 +++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 api/pipeline.py create mode 100644 api/router.py create mode 100644 tests/test_api.py diff --git a/api/pipeline.py b/api/pipeline.py new file mode 100644 index 0000000..b2b5cd0 --- /dev/null +++ b/api/pipeline.py @@ -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 diff --git a/api/router.py b/api/router.py new file mode 100644 index 0000000..84bc291 --- /dev/null +++ b/api/router.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7dc2ca6 --- /dev/null +++ b/tests/test_api.py @@ -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)