monitor/api/src/Controller/VoiceRecordingController.php
fds2610-bot 9426406ee5 Sprachaufnahme: Agent-Kachel, Aufnahme-Webseite und API-Warteschlange
Setzt den in #84 skizzierten Workflow um: record.html nimmt im
Handy-Browser eine Sprachnotiz per MediaRecorder auf und laedt sie
unauthentifiziert zu POST /api/v1/agents/{id}/recordings hoch. Ein
Klick auf die Personen-Figur jedes registrierten Agenten in der
Buero-Visualisierung verlinkt dorthin, ein Zaehler am Schreibtisch
zeigt offene Aufnahmen. Der Agent selbst holt seine Warteschlange
ueber die bestehenden Api-Key-authentifizierten Endpunkte ab
(GET .../recordings, GET /recordings/{id}/audio) und markiert eine
Aufnahme nach der Transkription per PATCH als erledigt - die
eigentliche Transkription (whisper.cpp/faster-whisper) bleibt
agent-seitig, siehe fds2610/Agent#17.

Schliesst #85
2026-09-03 21:26:28 +02:00

135 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Monitor\Api\Controller;
use InvalidArgumentException;
use Monitor\Api\Http\Request;
use Monitor\Api\Http\Response;
use Monitor\Api\Repository\AgentRepository;
use Monitor\Api\Repository\VoiceRecordingRepository;
/**
* Issue #85: Sprachaufnahme-Warteschlange. Zwei Zugriffsarten mit unterschiedlicher
* Berechtigung, analog zu HostConfigController:
*
* - Upload aus record.html (Handy-Browser eines Menschen, kein registrierter Agent) -
* unauthentifiziert wie die uebrigen von der UI ausgeloesten Schreibvorgaenge auf oeffentlich
* verlinkten Seiten; die Agenten-Id kommt als URL-Segment mit.
* - Abholen/Markieren durch den Agenten selbst - hinter dem bestehenden Agenten-API-Key,
* mit Ownership-Check wie bei AgentController::activity() (ein Agent sieht/holt nur seine
* eigene Warteschlange).
*/
final class VoiceRecordingController
{
public function __construct(
private readonly VoiceRecordingRepository $recordings,
private readonly AgentRepository $agents,
) {
}
/** @param array<string, string> $params */
public function upload(Request $request, array $params): Response
{
$agentId = (int) $params['id'];
if ($this->agents->findById($agentId) === null) {
return Response::error('Unbekannter Agent: ' . $agentId, 404);
}
$file = $request->files['audio'] ?? null;
if (!is_array($file) || !isset($file['tmp_name'], $file['error'])) {
return Response::error('Feld "audio" (Datei) ist erforderlich.', 422);
}
$error = (int) $file['error'];
if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
return Response::error('Datei zu gross.', 413);
}
if ($error !== UPLOAD_ERR_OK) {
return Response::error('Datei-Upload fehlgeschlagen.', 422);
}
$contents = @file_get_contents((string) $file['tmp_name']);
if ($contents === false) {
return Response::error('Datei-Upload fehlgeschlagen.', 422);
}
try {
$recording = $this->recordings->store($agentId, $contents, (string) ($file['type'] ?? ''));
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 422);
}
return Response::json(['recording' => $recording], 201);
}
/**
* @param array<string, string> $params
* @param array<string, mixed> $authenticatedAgent
*/
public function index(Request $request, array $params, array $authenticatedAgent): Response
{
if ((int) $params['id'] !== (int) $authenticatedAgent['id']) {
return Response::error('Ein Agent darf nur seine eigene Warteschlange abfragen.', 403);
}
$status = isset($request->query['status']) ? (string) $request->query['status'] : null;
try {
$recordings = $this->recordings->listForAgent((int) $params['id'], $status);
} catch (InvalidArgumentException $e) {
return Response::error($e->getMessage(), 400);
}
return Response::json(['recordings' => $recordings]);
}
/**
* @param array<string, string> $params
* @param array<string, mixed> $authenticatedAgent
*/
public function download(Request $request, array $params, array $authenticatedAgent): Response
{
$recording = $this->recordings->findById((int) $params['id']);
if ($recording === null) {
return Response::error('Aufnahme nicht gefunden.', 404);
}
if ((int) $recording['agent_id'] !== (int) $authenticatedAgent['id']) {
return Response::error('Diese Aufnahme gehoert einem anderen Agenten.', 403);
}
$bytes = $this->recordings->readAudio((int) $recording['id']);
if ($bytes === null) {
return Response::error('Audiodatei nicht gefunden.', 404);
}
return Response::binary($bytes, $recording['mime_type'], 200, [
'Content-Disposition' => 'attachment; filename="recording-' . $recording['id'] . '"',
]);
}
/**
* @param array<string, string> $params
* @param array<string, mixed> $authenticatedAgent
*/
public function markProcessed(Request $request, array $params, array $authenticatedAgent): Response
{
$recording = $this->recordings->findById((int) $params['id']);
if ($recording === null) {
return Response::error('Aufnahme nicht gefunden.', 404);
}
if ((int) $recording['agent_id'] !== (int) $authenticatedAgent['id']) {
return Response::error('Diese Aufnahme gehoert einem anderen Agenten.', 403);
}
$status = (string) ($request->body['status'] ?? '');
if ($status !== 'done') {
return Response::error('Feld "status" muss "done" sein.', 422);
}
$updated = $this->recordings->markDone((int) $recording['id']);
return Response::json(['recording' => $updated]);
}
}