Gesprächsaufnahmen mit lokalem Whisper und LLM verarbeiten
This commit is contained in:
1 parent
373081d33d
commit
55e5d34e83
39 files changed
+2931
-74
No files matched your search
@@ -2,6 +2,7 @@ package de.appcreation.swyxweb;
|
||||
|
||||
import de.appcreation.swyxweb.config.WebSocketProperties;
|
||||
import de.appcreation.swyxweb.config.WebhookProperties;
|
||||
import de.appcreation.swyxweb.transcription.TranscriptionProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@SpringBootApplication
|
||||
// Zeitpläne wie die nächtliche Job-Bereinigung (JobCleanupService).
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class })
|
||||
@EnableConfigurationProperties({ WebSocketProperties.class, WebhookProperties.class, TranscriptionProperties.class })
|
||||
public class BackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package de.appcreation.swyxweb.transcription;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/transcriptions")
|
||||
public class TranscriptionController {
|
||||
private final TranscriptionService service;
|
||||
private final TranscriptionProperties properties;
|
||||
|
||||
public TranscriptionController(TranscriptionService service, TranscriptionProperties properties) {
|
||||
this.service = service;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> config() {
|
||||
return Map.of("enabled", properties.enabled(), "maxBytes", TranscriptionService.MAX_BYTES);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
public TranscriptionJob upload(@RequestParam MultipartFile file, @RequestParam UUID recordingId,
|
||||
@RequestParam(defaultValue = "de") String language, @RequestParam(defaultValue = "meeting") String style) {
|
||||
return service.upload(file, recordingId, language, style);
|
||||
}
|
||||
|
||||
@GetMapping("/{jobId}")
|
||||
public TranscriptionJob get(@PathVariable String jobId) { return service.get(jobId); }
|
||||
|
||||
@PostMapping("/{jobId}/retry")
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
public TranscriptionJob retry(@PathVariable String jobId) { return service.retry(jobId); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.appcreation.swyxweb.transcription;
|
||||
|
||||
/** Ergebnis der lokalen Whisper-Verarbeitung mit optionaler LLM-Zusammenfassung. */
|
||||
public record TranscriptionJob(String id, String filename, String status, String stage,
|
||||
String error, String transcript, String formatted_transcript, String summary) {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package de.appcreation.swyxweb.transcription;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
@ConfigurationProperties("app.transcription")
|
||||
public record TranscriptionProperties(@DefaultValue("") String baseUrl) {
|
||||
public TranscriptionProperties {
|
||||
baseUrl = baseUrl == null ? "" : baseUrl.strip().replaceAll("/+$", "");
|
||||
if (!baseUrl.isEmpty()) {
|
||||
URI uri = URI.create(baseUrl);
|
||||
if (!("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))
|
||||
|| uri.getHost() == null || uri.getUserInfo() != null
|
||||
|| uri.getQuery() != null || uri.getFragment() != null) {
|
||||
throw new IllegalArgumentException("app.transcription.base-url muss eine HTTP(S)-Adresse ohne Zugangsdaten sein.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean enabled() { return !baseUrl.isEmpty(); }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package de.appcreation.swyxweb.transcription;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestClientResponseException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class TranscriptionService {
|
||||
public static final long MAX_BYTES = 500L * 1024 * 1024;
|
||||
private final TranscriptionProperties properties;
|
||||
private final RestClient client;
|
||||
|
||||
public TranscriptionService(TranscriptionProperties properties) {
|
||||
this.properties = properties;
|
||||
var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build());
|
||||
factory.setReadTimeout(Duration.ofMinutes(2));
|
||||
client = RestClient.builder().requestFactory(factory).build();
|
||||
}
|
||||
|
||||
public TranscriptionJob upload(MultipartFile file, UUID recordingId, String language, String style) {
|
||||
requireConfigured();
|
||||
if (!Set.of("auto", "de", "en", "fr", "es", "it", "nl", "pl").contains(language)
|
||||
|| !Set.of("standard", "short", "meeting").contains(style)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültige Sprache oder Zusammenfassungsart.");
|
||||
}
|
||||
validateWav(file);
|
||||
var headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType("audio/wav"));
|
||||
var body = new LinkedMultiValueMap<String, Object>();
|
||||
body.add("file", new HttpEntity<>(file.getResource(), headers));
|
||||
body.add("language", language);
|
||||
body.add("style", style);
|
||||
return invoke(() -> client.post().uri(properties.baseUrl() + "/api/jobs")
|
||||
.header("Idempotency-Key", "swyxweb:" + recordingId)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body)
|
||||
.retrieve().body(TranscriptionJob.class));
|
||||
}
|
||||
|
||||
public TranscriptionJob get(String jobId) {
|
||||
requireConfigured();
|
||||
validateJobId(jobId);
|
||||
return invoke(() -> client.get().uri(properties.baseUrl() + "/api/jobs/" + jobId)
|
||||
.retrieve().body(TranscriptionJob.class));
|
||||
}
|
||||
|
||||
public TranscriptionJob retry(String jobId) {
|
||||
requireConfigured();
|
||||
validateJobId(jobId);
|
||||
return invoke(() -> client.post().uri(properties.baseUrl() + "/api/jobs/" + jobId + "/retry")
|
||||
.retrieve().body(TranscriptionJob.class));
|
||||
}
|
||||
|
||||
private void requireConfigured() {
|
||||
if (!properties.enabled()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Die Transkription ist noch nicht konfiguriert (APP_TRANSCRIPTION_BASE_URL).");
|
||||
}
|
||||
|
||||
private void validateJobId(String jobId) {
|
||||
if (!jobId.matches("[a-f0-9]{32}")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültige Auftragskennung.");
|
||||
}
|
||||
|
||||
private void validateWav(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null || !filename.toLowerCase(Locale.ROOT).endsWith(".wav") || file.getSize() < 12) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Bitte eine nicht leere WAV-Datei auswählen.");
|
||||
}
|
||||
if (file.getSize() > MAX_BYTES) throw new ResponseStatusException(HttpStatus.CONTENT_TOO_LARGE, "Maximal 500 MB pro Aufnahme.");
|
||||
try (var input = file.getInputStream()) {
|
||||
byte[] header = input.readNBytes(12);
|
||||
String kind = new String(header, 0, Math.min(4, header.length), StandardCharsets.US_ASCII);
|
||||
if (header.length != 12 || !(kind.equals("RIFF") || kind.equals("RF64"))
|
||||
|| !new String(header, 8, 4, StandardCharsets.US_ASCII).equals("WAVE")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Die Datei hat keinen WAV-Dateikopf.");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Die WAV-Datei konnte nicht gelesen werden.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private TranscriptionJob invoke(Supplier<TranscriptionJob> action) {
|
||||
try {
|
||||
TranscriptionJob job = action.get();
|
||||
if (job == null || job.id() == null || !job.id().matches("[a-f0-9]{32}") || job.status() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Der Transkriptionsdienst lieferte keinen gültigen Auftrag.");
|
||||
}
|
||||
return job;
|
||||
} catch (RestClientResponseException e) {
|
||||
HttpStatusCode status = e.getStatusCode().is4xxClientError()
|
||||
? e.getStatusCode() : HttpStatus.BAD_GATEWAY;
|
||||
throw new ResponseStatusException(status, "Der Transkriptionsdienst hat die Anfrage abgelehnt (HTTP " + e.getStatusCode().value() + ").", e);
|
||||
} catch (RestClientException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||
"Der Transkriptionsdienst ist nicht erreichbar oder hat nicht rechtzeitig geantwortet. Die Übergabe kann erneut versucht werden.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ public record LineState(
|
||||
String peerNumber,
|
||||
String peerName,
|
||||
boolean busy,
|
||||
boolean selected) {
|
||||
boolean selected,
|
||||
Integer callId,
|
||||
boolean recordingRequested) {
|
||||
|
||||
public static final String FREE = "LSInactive";
|
||||
public static final String RINGING = "LSRinging";
|
||||
@@ -32,7 +34,7 @@ public record LineState(
|
||||
private static final String NO_PEER = "unbekannt";
|
||||
|
||||
public static LineState free(int line) {
|
||||
return new LineState(line, FREE, 0, "frei", NO_PEER, "", "", false, false);
|
||||
return new LineState(line, FREE, 0, "frei", NO_PEER, "", "", false, false, null, false);
|
||||
}
|
||||
|
||||
public static LineState ringing(int line, String number, String name) {
|
||||
@@ -49,7 +51,12 @@ public record LineState(
|
||||
}
|
||||
|
||||
public LineState connected() {
|
||||
return occupied(line, ACTIVE, 6, "verbunden", peerNumber, peerName);
|
||||
return occupied(line, ACTIVE, 6, "verbunden", peerNumber, peerName).withCall(callId, recordingRequested);
|
||||
}
|
||||
|
||||
public LineState withCall(Integer id, boolean recording) {
|
||||
return new LineState(line, state, stateCode, stateText, peer, peerNumber, peerName,
|
||||
busy, selected, id, recording);
|
||||
}
|
||||
|
||||
/** Nur für den Mock – die App kennt dieses Feld nicht, also nicht serialisieren. */
|
||||
@@ -65,7 +72,7 @@ public record LineState(
|
||||
peerOf(number, name),
|
||||
number == null ? "" : number,
|
||||
name == null ? "" : name,
|
||||
true, true);
|
||||
true, true, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,8 +33,8 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
* <li>Beim Verbinden: {@code {"app":"SwyxTray",…,"type":"hello"}}, danach ein Snapshot.</li>
|
||||
* <li>Kommando: {@code {"id":7,"cmd":"call","number":"…"}}
|
||||
* → {@code {"id":7,"ok":true,"line":2,"type":"result"}}</li>
|
||||
* <li><b>Keine Ereignisnachrichten.</b> Jede Änderung geht als vollständiger
|
||||
* {@code snapshot} über alle Leitungen raus – auch ein eingehender Anruf.</li>
|
||||
* <li>Änderungen gehen als {@code snapshot} und {@code call}-Ereignisse raus.
|
||||
* Aufnahmebefehle verwenden Leitung und Anrufkennung wie Protokoll 10.</li>
|
||||
* </ul>
|
||||
*
|
||||
* Ein eingehender Anruf lässt sich über {@code POST /api/mock/incoming-call} auslösen.
|
||||
@@ -73,7 +73,7 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
"SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH");
|
||||
|
||||
/** Protokollstand der echten App seit dem Adress-Cache. */
|
||||
private static final int PROTOCOL_VERSION = 8;
|
||||
private static final int PROTOCOL_VERSION = 10;
|
||||
|
||||
/** So viele Treffer meldet die echte App höchstens; gekürzt wird ohne Hinweis. */
|
||||
private static final int CONTACT_RESULT_LIMIT = 100;
|
||||
@@ -90,11 +90,13 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
// Vorgetäuschte Tabs des Firefox-Plugins, in der Reihenfolge des Öffnens.
|
||||
private final Map<Integer, MockTab> tabs = Collections.synchronizedMap(new LinkedHashMap<>());
|
||||
private final AtomicInteger nextTabId = new AtomicInteger(41);
|
||||
private final AtomicInteger nextCallId = new AtomicInteger(100);
|
||||
private final AtomicInteger nextSession = new AtomicInteger();
|
||||
// Ist der Adress-Cache gefüllt? Zum Umschalten auf die Rückfallebene.
|
||||
private volatile boolean addressCacheFilled = true;
|
||||
// Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist.
|
||||
private final Object sendLock = new Object();
|
||||
private Map<Integer, LineState> publishedLines = Map.of();
|
||||
|
||||
public SwyxTrayMockHandler(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
@@ -125,7 +127,7 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||
protected synchronized void handleTextMessage(WebSocketSession session, TextMessage message) {
|
||||
JsonNode request;
|
||||
try {
|
||||
request = mapper.readTree(message.getPayload());
|
||||
@@ -182,12 +184,18 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
|
||||
Integer line = switch (cmd) {
|
||||
case "call" -> dial(request.path("number").asString(null));
|
||||
case "answer" -> answer(requiredLine(request));
|
||||
case "answer" -> answer(requiredLine(request), request.path("callId"));
|
||||
case "hangup" -> hangup(requiredLine(request));
|
||||
case "recording.start", "recording.stop" -> recording(requiredLine(request), request.path("callId"), "recording.start".equals(cmd));
|
||||
case "ping", "status" -> null;
|
||||
default -> throw new IllegalArgumentException("Unbekanntes Kommando '" + cmd + "'.");
|
||||
};
|
||||
sendText(session, toJson(result(id, true, line, null)));
|
||||
var response = result(id, true, line, null);
|
||||
if (cmd.startsWith("recording.")) {
|
||||
response.put("callId", lines.get(line).callId());
|
||||
response.put("recordingRequested", lines.get(line).recordingRequested());
|
||||
}
|
||||
sendText(session, toJson(response));
|
||||
broadcastSnapshot();
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
sendText(session, toJson(result(id, false, null, e.getMessage())));
|
||||
@@ -205,13 +213,25 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
throw new IllegalArgumentException("Parameter 'number' fehlt.");
|
||||
}
|
||||
int line = freeLine();
|
||||
lines.put(line, LineState.dialing(line, number.trim(), null));
|
||||
lines.put(line, LineState.dialing(line, number.trim(), null).withCall(nextCallId.incrementAndGet(), false));
|
||||
log.info("SwyxTray-Mock: Wähle {} auf Leitung {}", number, line);
|
||||
return line;
|
||||
}
|
||||
|
||||
private int answer(int line) {
|
||||
lines.put(line, occupied(line).connected());
|
||||
private int answer(int line, JsonNode callId) {
|
||||
var current = occupied(line);
|
||||
if (!current.isRinging()) throw new IllegalStateException("Die Leitung klingelt nicht mehr.");
|
||||
if (!callId.isMissingNode() && current.callId() != callId.asInt()) throw new IllegalStateException("Anruf hat sich geändert.");
|
||||
lines.put(line, current.connected());
|
||||
return line;
|
||||
}
|
||||
|
||||
private int recording(int line, JsonNode callId, boolean start) {
|
||||
var current = occupied(line);
|
||||
if (!callId.isIntegralNumber() || current.callId() != callId.asInt())
|
||||
throw new IllegalArgumentException("Ungültige oder veraltete Anrufkennung.");
|
||||
if (!LineState.ACTIVE.equals(current.state())) throw new IllegalStateException("Keine Sprechverbindung.");
|
||||
lines.put(line, current.withCall(current.callId(), start));
|
||||
return line;
|
||||
}
|
||||
|
||||
@@ -390,12 +410,12 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
}
|
||||
|
||||
/** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */
|
||||
public LineState simulateIncomingCall(String number, String name) {
|
||||
public synchronized LineState simulateIncomingCall(String number, String name) {
|
||||
int line = freeLine();
|
||||
LineState state = LineState.ringing(
|
||||
line,
|
||||
number == null || number.isBlank() ? "+493012345" : number.trim(),
|
||||
name == null || name.isBlank() ? null : name.trim());
|
||||
name == null || name.isBlank() ? null : name.trim()).withCall(nextCallId.incrementAndGet(), false);
|
||||
lines.put(line, state);
|
||||
log.info("SwyxTray-Mock: Eingehender Anruf von {} auf Leitung {}", state.peer(), line);
|
||||
broadcastSnapshot();
|
||||
@@ -535,9 +555,24 @@ public class SwyxTrayMockHandler extends TextWebSocketHandler {
|
||||
|
||||
private void broadcastSnapshot() {
|
||||
String json = toJson(snapshot());
|
||||
synchronized (sendLock) {
|
||||
sessions.values().forEach(session -> sendText(session, json));
|
||||
var messages = new ArrayList<String>();
|
||||
messages.add(json);
|
||||
for (var current : lines.values()) {
|
||||
var previous = publishedLines.get(current.line());
|
||||
if (previous != null && !previous.isFree() && (current.isFree() || !previous.callId().equals(current.callId())))
|
||||
messages.add(toJson(callEvent("ended", previous)));
|
||||
if (!current.isFree() && (previous == null || !current.state().equals(previous.state())))
|
||||
messages.add(toJson(callEvent(current.isRinging() ? "incoming" : LineState.ACTIVE.equals(current.state()) ? "connected" : "outgoing", current)));
|
||||
}
|
||||
publishedLines = Map.copyOf(lines);
|
||||
synchronized (sendLock) {
|
||||
sessions.values().forEach(session -> messages.forEach(message -> sendText(session, message)));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> callEvent(String event, LineState state) {
|
||||
return Map.of("type", "call", "event", event, "line", state.line(), "callId", state.callId(),
|
||||
"peer", state.peer(), "peerNumber", state.peerNumber(), "peerName", state.peerName());
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
|
||||
@@ -39,3 +39,11 @@ app.webhook.history=50
|
||||
app.webhook.max-size=262144
|
||||
|
||||
logging.level.de.appcreation.swyxweb=DEBUG
|
||||
|
||||
# Ziel aus Sicht des Backends. Docker setzt den lokalen Whisper-Worker auf
|
||||
# http://127.0.0.1:8765. Ohne konfigurierte Worker-Adresse bleibt die Übergabe deaktiviert.
|
||||
app.transcription.base-url=
|
||||
spring.servlet.multipart.max-file-size=500MB
|
||||
spring.servlet.multipart.max-request-size=501MB
|
||||
spring.servlet.multipart.file-size-threshold=0
|
||||
spring.mvc.problemdetails.enabled=true
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package de.appcreation.swyxweb.transcription;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
class TranscriptionPropertiesTests {
|
||||
@Test
|
||||
void bindsLocalWorkerUnderTranscriptionPrefix() {
|
||||
new ApplicationContextRunner().withUserConfiguration(PropertiesConfig.class)
|
||||
.withPropertyValues("app.transcription.base-url=http://127.0.0.1:8765/")
|
||||
.run(context -> {
|
||||
var properties = context.getBean(TranscriptionProperties.class);
|
||||
assertThat(properties.baseUrl()).isEqualTo("http://127.0.0.1:8765");
|
||||
assertThat(properties.enabled()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(TranscriptionProperties.class)
|
||||
static class PropertiesConfig {}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package de.appcreation.swyxweb.transcription;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class TranscriptionServiceTests {
|
||||
private static final String JOB_ID = "0123456789abcdef0123456789abcdef";
|
||||
private static final byte[] WAV = "RIFFxxxxWAVEfmt data".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
@Test
|
||||
void streamsMultipartAndPreservesIdempotencyKeyAndResults() throws Exception {
|
||||
var received = new AtomicReference<String>();
|
||||
var key = new AtomicReference<String>();
|
||||
var method = new AtomicReference<String>();
|
||||
var requests = new AtomicInteger();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/api/jobs", exchange -> {
|
||||
requests.incrementAndGet();
|
||||
received.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
key.set(exchange.getRequestHeaders().getFirst("Idempotency-Key"));
|
||||
method.set(exchange.getRequestMethod());
|
||||
byte[] body = ("{\"id\":\"" + JOB_ID + "\",\"filename\":\"call.wav\",\"status\":\"done\",\"stage\":\"Fertig\","
|
||||
+ "\"summary\":\"Zusammenfassung\",\"transcript\":\"Gespräch\",\"segments\":[],\"audio_ext\":\".wav\"}").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(202, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
try {
|
||||
var service = new TranscriptionService(new TranscriptionProperties("http://127.0.0.1:" + server.getAddress().getPort()));
|
||||
var id = UUID.randomUUID();
|
||||
var job = service.upload(new MockMultipartFile("file", "call.wav", "audio/wav", WAV), id, "de", "meeting");
|
||||
assertThat(job.id()).isEqualTo(JOB_ID);
|
||||
assertThat(job.summary()).isEqualTo("Zusammenfassung");
|
||||
assertThat(received.get()).contains("name=\"file\"; filename=\"call.wav\"", "audio/wav", "RIFFxxxxWAVE", "meeting", "de");
|
||||
assertThat(key.get()).isEqualTo("swyxweb:" + id);
|
||||
assertThat(service.get(JOB_ID).transcript()).isEqualTo("Gespräch");
|
||||
assertThat(method.get()).isEqualTo("GET");
|
||||
service.retry(JOB_ID);
|
||||
assertThat(method.get()).isEqualTo("POST");
|
||||
assertThat(requests).hasValue(3);
|
||||
} finally { server.stop(0); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledIntegrationFailsClearly() {
|
||||
var service = new TranscriptionService(new TranscriptionProperties(""));
|
||||
assertThatThrownBy(() -> service.upload(new MockMultipartFile("file", WAV), UUID.randomUUID(), "de", "meeting"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("503").hasMessageContaining("APP_TRANSCRIPTION_BASE_URL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidFilesOptionsAndPathsBeforeNetworkAccess() {
|
||||
var service = new TranscriptionService(new TranscriptionProperties("http://127.0.0.1:1"));
|
||||
for (var file : new MockMultipartFile[] {
|
||||
new MockMultipartFile("file", "call.mp3", "audio/wav", WAV),
|
||||
new MockMultipartFile("file", "call.wav", "audio/wav", "not an audio file".getBytes()),
|
||||
new MockMultipartFile("file", "empty.wav", "audio/wav", new byte[0]) }) {
|
||||
assertThatThrownBy(() -> service.upload(file, UUID.randomUUID(), "de", "meeting")).hasMessageContaining("400");
|
||||
}
|
||||
assertThatThrownBy(() -> service.upload(new MockMultipartFile("file", "call.wav", "audio/wav", WAV), UUID.randomUUID(), "de", "invalid"))
|
||||
.hasMessageContaining("400");
|
||||
assertThatThrownBy(() -> service.get("../health")).hasMessageContaining("400");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableServerIsNotReportedAsSuccessfulUpload() {
|
||||
var service = new TranscriptionService(new TranscriptionProperties("http://127.0.0.1:1"));
|
||||
assertThatThrownBy(() -> service.get(JOB_ID)).hasMessageContaining("502").hasMessageContaining("nicht erreichbar");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.appcreation.swyxweb.websocket;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class RecordingProtocolTests {
|
||||
@Test
|
||||
void answerRecordStopEndAndRejectStaleCall() throws Exception {
|
||||
var mapper = new ObjectMapper();
|
||||
var handler = new SwyxTrayMockHandler(mapper);
|
||||
var session = mock(WebSocketSession.class);
|
||||
when(session.getId()).thenReturn("recording-test");
|
||||
when(session.isOpen()).thenReturn(true);
|
||||
List<String> messages = new ArrayList<>();
|
||||
doAnswer(invocation -> { messages.add(((TextMessage) invocation.getArgument(0)).getPayload()); return null; })
|
||||
.when(session).sendMessage(any());
|
||||
handler.afterConnectionEstablished(session);
|
||||
assertThat(mapper.readTree(messages.getFirst()).path("protocol").asInt()).isEqualTo(10);
|
||||
var incoming = handler.simulateIncomingCall("123", "Test");
|
||||
int line = incoming.line();
|
||||
int callId = incoming.callId();
|
||||
handler.handleTextMessage(session, command(1, "recording.start", line, callId));
|
||||
assertThat(mapper.readTree(messages.getLast()).path("ok").asBoolean()).isFalse();
|
||||
handler.handleTextMessage(session, command(2, "answer", line, callId));
|
||||
handler.handleTextMessage(session, command(3, "recording.start", line, callId));
|
||||
var result = messages.stream().map(mapper::readTree).filter(n -> n.path("id").asInt() == 3).findFirst().orElseThrow();
|
||||
assertThat(result.path("ok").asBoolean()).isTrue();
|
||||
assertThat(result.path("recordingRequested").asBoolean()).isTrue();
|
||||
handler.handleTextMessage(session, command(4, "recording.stop", line, callId));
|
||||
result = messages.stream().map(mapper::readTree).filter(n -> n.path("id").asInt() == 4).findFirst().orElseThrow();
|
||||
assertThat(result.path("recordingRequested").asBoolean()).isFalse();
|
||||
handler.handleTextMessage(session, command(5, "hangup", line, callId));
|
||||
var ended = mapper.readTree(messages.getLast());
|
||||
assertThat(ended.path("event").asString()).isEqualTo("ended");
|
||||
assertThat(ended.path("callId").asInt()).isEqualTo(callId);
|
||||
var next = handler.simulateIncomingCall("123", "Test");
|
||||
assertThat(next.callId()).isNotEqualTo(callId);
|
||||
handler.handleTextMessage(session, command(6, "recording.start", line, callId));
|
||||
assertThat(mapper.readTree(messages.getLast()).path("ok").asBoolean()).isFalse();
|
||||
}
|
||||
private TextMessage command(int id, String command, int line, int callId) {
|
||||
return new TextMessage("{\"id\":" + id + ",\"cmd\":\"" + command + "\",\"line\":" + line + ",\"callId\":" + callId + "}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user