feat: API router + pipeline — toggle, status, transcripts, WebSocket, auth stub
This commit is contained in:
@@ -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
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user