Sprachaufnahmen: eine Warteschlange fuer alle statt Zuweisung an Agenten
In Produktion lag eine Sprachnachricht unbearbeitet in der Warteschlange des
OPs-Agenten: aufgenommen wurde ueber seine Figur, abgeholt wird aber nur von
der Maschine, auf der whisper.cpp laeuft. Die Agent-Zuweisung hat eine
Zustaendigkeit vorgetaeuscht, die es nicht gibt - welches Ticket in welchem
Repo entsteht, entscheidet der abholende Bot anhand des Transkripts.
- Migration 007: voice_recordings ohne agent_id. Bestehende Aufnahmen behalten
ihren Status, die haengengebliebene wandert damit in die globale Warteschlange.
- Migration 008: neue Agenten-Rolle "voice" fuer den Voice-Bot.
- Endpunkte ohne Agent im Pfad: POST/GET /api/v1/recordings,
GET /api/v1/recordings/{id}/audio, PATCH /api/v1/recordings/{id} - und ohne
Ownership-Check, an dem die Abholung mit fremdem Key bisher scheiterte.
- GET /api/v1/agents traegt pending_recordings jetzt neben der Liste statt an
jedem Agenten.
- Frontend: nur noch die Figur des Voice-Bots verlinkt auf record.html (ohne
?agent=), Zaehler und Mikrofon-Deko sitzen an seinem Schreibtisch. Die
Aufnahmeseite nennt den abholenden Bot und bittet darum, das Projekt am
Anfang der Aufnahme zu nennen.
Die Bot-Seite (bin/voice-recording-agent.sh) folgt in fds2610/Agent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2818585876
commit
be1d61350b
19 changed files with 467 additions and 302 deletions
68
API.md
68
API.md
|
|
@ -65,7 +65,7 @@ Fehlt oder passt ein Key nicht, antwortet die API mit `401 Unauthorized`.
|
||||||
|
|
||||||
## Agenten
|
## Agenten
|
||||||
|
|
||||||
Rollen (F1/F2): `entwickler`, `product_owner`, `qa`, `ops`, `user`. Ein Agent bekommt bei der
|
Rollen (F1/F2): `entwickler`, `product_owner`, `qa`, `ops`, `voice`, `user`. Ein Agent bekommt bei der
|
||||||
Registrierung fest einen von `MONITOR_DESK_COUNT` (Standard 8) Schreibtischen zugewiesen
|
Registrierung fest einen von `MONITOR_DESK_COUNT` (Standard 8) Schreibtischen zugewiesen
|
||||||
(niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester
|
(niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester
|
||||||
Sitzplatz). Sind alle Schreibtische belegt, wird trotzdem registriert, aber ohne
|
Sitzplatz). Sind alle Schreibtische belegt, wird trotzdem registriert, aber ohne
|
||||||
|
|
@ -77,6 +77,13 @@ Rollen einen Schreibtisch, in der Buero-Visualisierung mit dem Label "OPs" und e
|
||||||
Zahnrad als Deko. Registrieren laesst sich der lokale OPs-Agent mit
|
Zahnrad als Deko. Registrieren laesst sich der lokale OPs-Agent mit
|
||||||
`register-ops-agent.sh`, das zugleich `monitor-api.conf` schreibt.
|
`register-ops-agent.sh`, das zugleich `monitor-api.conf` schreibt.
|
||||||
|
|
||||||
|
`voice` ist die Rolle des Voice-Bots: des Bots, der die Sprachaufnahmen abholt,
|
||||||
|
transkribiert und daraus Tickets anlegt (siehe [Sprachaufnahmen](#sprachaufnahmen)). Auch
|
||||||
|
er bekommt einen Schreibtisch - mit einem Mikrofon als Deko, dem Zaehler offener Aufnahmen
|
||||||
|
und als einzige Figur einem Link auf `record.html`. Registriert wird er von der Maschine
|
||||||
|
aus, auf der er laeuft: `scripts/register-voice-agent.sh` in
|
||||||
|
[fds2610/Agent](https://codeberg.org/fds2610/Agent).
|
||||||
|
|
||||||
### `POST /api/v1/agents` — Agent registrieren
|
### `POST /api/v1/agents` — Agent registrieren
|
||||||
|
|
||||||
Auth: `X-Admin-Key`.
|
Auth: `X-Admin-Key`.
|
||||||
|
|
@ -101,10 +108,11 @@ Auth: `X-Admin-Key`.
|
||||||
|
|
||||||
### `GET /api/v1/agents` — Alle Agenten
|
### `GET /api/v1/agents` — Alle Agenten
|
||||||
|
|
||||||
Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`. Jeder Agent traegt
|
Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ], "pending_recordings": 0 }`.
|
||||||
zusaetzlich `pending_recordings` (Issue #85): Anzahl offener Sprachaufnahmen in seiner
|
`pending_recordings` ist die Anzahl offener Sprachaufnahmen (`status: "new"`) in der
|
||||||
Warteschlange (`status: "new"`), Grundlage fuer den Zaehler auf seinem Schreibtisch in der
|
gemeinsamen Warteschlange - ein Wert fuers ganze Buero, kein Feld je Agent, seit die
|
||||||
Buero-Visualisierung, siehe [Sprachaufnahmen](#sprachaufnahmen).
|
Warteschlange niemandem mehr gehoert. Das Frontend zeichnet den Zaehler damit an den
|
||||||
|
Schreibtisch des Voice-Bots, siehe [Sprachaufnahmen](#sprachaufnahmen).
|
||||||
|
|
||||||
Oeffentlich, unauthentifiziert (siehe [Grundlagen](#grundlagen)) - damit laesst sich
|
Oeffentlich, unauthentifiziert (siehe [Grundlagen](#grundlagen)) - damit laesst sich
|
||||||
`last_seen_at`/`is_present` jedes Agenten jederzeit von aussen pruefen, z.B. auf der
|
`last_seen_at`/`is_present` jedes Agenten jederzeit von aussen pruefen, z.B. auf der
|
||||||
|
|
@ -202,21 +210,29 @@ curl -X POST "$MONITOR_API_BASE/agents/$ID/activity" \
|
||||||
|
|
||||||
## Sprachaufnahmen
|
## Sprachaufnahmen
|
||||||
|
|
||||||
Issue #85 (Folge-Ticket aus #84): Sprachnotizen fuer einen Agenten, aufgenommen ueber
|
Sprachnotizen, aufgenommen ueber `record.html` im Handy-Browser. Die eigentliche
|
||||||
`record.html` im Handy-Browser. Die eigentliche Transkription passiert agent-seitig
|
Transkription passiert bot-seitig (whisper.cpp, siehe `bin/voice-recording-agent.sh` in
|
||||||
(whisper.cpp/faster-whisper, siehe [fds2610/Agent#17](https://codeberg.org/fds2610/Agent/issues/17)) -
|
[fds2610/Agent](https://codeberg.org/fds2610/Agent)) - diese API liefert nur die Rohaufnahme
|
||||||
diese API liefert nur die Rohaufnahme mit Status.
|
mit Status.
|
||||||
|
|
||||||
In der Buero-Visualisierung verlinkt ein Klick auf die Personen-Figur jedes registrierten
|
**Eine Warteschlange fuer alle, ohne Empfaenger.** Bis einschliesslich Migration
|
||||||
Agenten auf `record.html?agent=‹id›`; ein kleiner Zaehler an seinem Schreibtisch zeigt, wie
|
`006_voice_recordings.sql` hing jede Aufnahme an einer Agenten-Id, und jede Agentenfigur in
|
||||||
viele Aufnahmen dort noch auf Abholung warten (`pending_recordings`, siehe
|
der Visualisierung verlinkte auf ihre eigene Warteschlange. Das hat Warteschlangen erzeugt,
|
||||||
[Agenten](#agenten)).
|
die nie jemand geleert hat: abgeholt wird nur von dem Bot, auf dem whisper.cpp laeuft.
|
||||||
|
Seit `007_voice_recordings_global.sql` gibt es genau eine Warteschlange; welches Ticket in
|
||||||
|
welchem Repo entsteht, entscheidet der abholende Bot anhand des Transkripts - nicht die
|
||||||
|
Aufnahme anhand einer Id.
|
||||||
|
|
||||||
### `POST /api/v1/agents/{id}/recordings` — Aufnahme hochladen
|
Abgeholt wird sie vom **Voice-Bot**: einem Agenten mit der Rolle `voice` (siehe
|
||||||
|
[Agenten](#agenten)). In der Buero-Visualisierung verlinkt nur seine Personen-Figur auf
|
||||||
|
`record.html`, und der Zaehler an seinem Schreibtisch zeigt, wie viele Aufnahmen noch auf
|
||||||
|
Abholung warten (`pending_recordings`).
|
||||||
|
|
||||||
|
### `POST /api/v1/recordings` — Aufnahme hochladen
|
||||||
|
|
||||||
Auth: keine. Der Upload kommt aus `record.html`, einer oeffentlich verlinkten Seite im
|
Auth: keine. Der Upload kommt aus `record.html`, einer oeffentlich verlinkten Seite im
|
||||||
Handy-Browser eines Menschen, nicht von einem registrierten Agenten - es gibt dort keinen
|
Handy-Browser eines Menschen, nicht von einem registrierten Agenten - es gibt dort keinen
|
||||||
API-/Admin-Key einzugeben. Die Agenten-Id kommt als URL-Segment mit.
|
API-/Admin-Key einzugeben.
|
||||||
|
|
||||||
Request: `multipart/form-data` mit Feld `audio` (Datei). Erlaubte MIME-Typen: `audio/webm`,
|
Request: `multipart/form-data` mit Feld `audio` (Datei). Erlaubte MIME-Typen: `audio/webm`,
|
||||||
`audio/ogg`, `audio/mp4`, `audio/mpeg`, `audio/wav`, `audio/x-wav` - andere lehnt der
|
`audio/ogg`, `audio/mp4`, `audio/mpeg`, `audio/wav`, `audio/x-wav` - andere lehnt der
|
||||||
|
|
@ -227,34 +243,34 @@ Endpunkt mit `422` ab. Groessenlimit: `MONITOR_MAX_RECORDING_BYTES` (Default 25
|
||||||
// Response 201
|
// Response 201
|
||||||
{
|
{
|
||||||
"recording": {
|
"recording": {
|
||||||
"id": 1, "agent_id": 3, "mime_type": "audio/webm", "size_bytes": 48213,
|
"id": 1, "mime_type": "audio/webm", "size_bytes": 48213,
|
||||||
"status": "new", "created_at": "2026-09-03T10:00:00Z", "processed_at": null
|
"status": "new", "created_at": "2026-09-03T10:00:00Z", "processed_at": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`404`, falls die Agenten-Id nicht existiert.
|
### `GET /api/v1/recordings` — Warteschlange abfragen
|
||||||
|
|
||||||
### `GET /api/v1/agents/{id}/recordings` — Eigene Warteschlange abfragen
|
Auth: `X-Api-Key` eines beliebigen registrierten Agenten. Keinen Ownership-Check mehr: die
|
||||||
|
Warteschlange gehoert niemandem, in der Praxis fragt sie der Voice-Bot alle fuenf Minuten
|
||||||
Auth: `X-Api-Key` des betroffenen Agenten (ein Agent darf nur seine eigene Warteschlange
|
ab. Ein zweiter Bot mit eigenem Key waere sonst ohne Not ausgesperrt.
|
||||||
abfragen, sonst `403`) - fuer das regelmaessige Abfragen aus [fds2610/Agent#17](https://codeberg.org/fds2610/Agent/issues/17).
|
|
||||||
|
|
||||||
Query-Parameter `status` optional (`new` oder `done`), ohne Filter kommen beide zurueck.
|
Query-Parameter `status` optional (`new` oder `done`), ohne Filter kommen beide zurueck.
|
||||||
|
|
||||||
Response: `{ "recordings": [ ‹Aufnahme-Objekte wie oben› ] }`, sortiert nach `created_at`.
|
Response: `{ "recordings": [ ‹Aufnahme-Objekte wie oben› ] }`, aelteste zuerst
|
||||||
|
(`created_at` aufsteigend) - in dieser Reihenfolge arbeitet der Bot sie ab.
|
||||||
|
|
||||||
### `GET /api/v1/recordings/{id}/audio` — Audiodatei herunterladen
|
### `GET /api/v1/recordings/{id}/audio` — Audiodatei herunterladen
|
||||||
|
|
||||||
Auth: `X-Api-Key` des Agenten, dem die Aufnahme gehoert (sonst `403`).
|
Auth: `X-Api-Key` eines registrierten Agenten.
|
||||||
|
|
||||||
Response: die Rohdatei mit dem beim Upload erkannten `Content-Type`, kein JSON.
|
Response: die Rohdatei mit dem beim Upload erkannten `Content-Type`, kein JSON.
|
||||||
|
|
||||||
### `PATCH /api/v1/recordings/{id}` — Als verarbeitet markieren
|
### `PATCH /api/v1/recordings/{id}` — Als verarbeitet markieren
|
||||||
|
|
||||||
Auth: `X-Api-Key` des Agenten, dem die Aufnahme gehoert (sonst `403`). Nach erfolgreicher
|
Auth: `X-Api-Key` eines registrierten Agenten. Nach erfolgreicher Abholung/Transkription
|
||||||
Abholung/Transkription markiert der Agent die Aufnahme so, damit sie nicht erneut in
|
markiert der Bot die Aufnahme so, damit sie nicht erneut in
|
||||||
`GET .../recordings?status=new` auftaucht.
|
`GET /api/v1/recordings?status=new` auftaucht.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// Request
|
// Request
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ final class Application
|
||||||
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
$this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret());
|
||||||
|
|
||||||
$agentController = new AgentController($agents, $recordings);
|
$agentController = new AgentController($agents, $recordings);
|
||||||
$recordingController = new VoiceRecordingController($recordings, $agents);
|
$recordingController = new VoiceRecordingController($recordings);
|
||||||
// Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container,
|
// Issue #51: der "Board neu einlesen"-Knopf synchronisiert direkt im api-Container,
|
||||||
// unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php)
|
// unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php)
|
||||||
// - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden.
|
// - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden.
|
||||||
|
|
@ -160,21 +160,23 @@ final class Application
|
||||||
'agent'
|
'agent'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sprachaufnahmen (Issue #85): Upload aus record.html ist bewusst unauthentifiziert -
|
// Sprachaufnahmen: Upload aus record.html ist bewusst unauthentifiziert - die
|
||||||
// die Aufnahme-Webseite laeuft im Handy-Browser eines Menschen, nicht eines
|
// Aufnahme-Webseite laeuft im Handy-Browser eines Menschen, nicht eines registrierten
|
||||||
// registrierten Agenten, es gibt dort also keinen Api-/Admin-Key einzugeben. Abholen/
|
// Agenten, es gibt dort also keinen Api-/Admin-Key einzugeben. Abholen/Markieren
|
||||||
// Markieren dagegen hinter dem Agenten-Key wie die uebrigen Bot-Vorgaenge, mit
|
// dagegen hinter dem Agenten-Key wie die uebrigen Bot-Vorgaenge.
|
||||||
// Ownership-Check in VoiceRecordingController (ein Agent sieht nur seine eigene
|
//
|
||||||
// Warteschlange).
|
// Eine Warteschlange fuer alle, ohne Agent-Id im Pfad (bis 006_voice_recordings.sql
|
||||||
|
// hing jede Aufnahme an einem Agenten): der abholende Voice-Bot entscheidet anhand
|
||||||
|
// des Transkripts, in welchem Repo das Ticket entsteht.
|
||||||
$this->router->add(
|
$this->router->add(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/v1/agents/{id}/recordings',
|
'/api/v1/recordings',
|
||||||
fn (Request $r, array $p) => $recordingController->upload($r, $p)
|
fn (Request $r, array $p) => $recordingController->upload($r)
|
||||||
);
|
);
|
||||||
$this->router->add(
|
$this->router->add(
|
||||||
'GET',
|
'GET',
|
||||||
'/api/v1/agents/{id}/recordings',
|
'/api/v1/recordings',
|
||||||
fn (Request $r, array $p, array $agent) => $recordingController->index($r, $p, $agent),
|
fn (Request $r, array $p, array $agent) => $recordingController->index($r, $agent),
|
||||||
'agent'
|
'agent'
|
||||||
);
|
);
|
||||||
$this->router->add(
|
$this->router->add(
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,17 @@ final class AgentController
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #85: jeder Agent traegt zusaetzlich pending_recordings - die Anzahl offener
|
* Neben den Agenten steht pending_recordings: die Anzahl offener Sprachaufnahmen in der
|
||||||
* Sprachaufnahmen in seiner Warteschlange, Grundlage fuer den Zaehler auf seinem
|
* Warteschlange. Ein Wert fuer das ganze Buero, kein Feld je Agent - die Warteschlange
|
||||||
* Schreibtisch in der Buero-Visualisierung.
|
* ist seit 007_voice_recordings_global.sql global. Das Frontend zeichnet den Zaehler
|
||||||
|
* damit an den Schreibtisch des Voice-Bots (Rolle "voice"), der sie abarbeitet.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$pendingCounts = $this->recordings->pendingCountsByAgent();
|
return Response::json([
|
||||||
|
'agents' => $this->agents->all(),
|
||||||
$agents = array_map(
|
'pending_recordings' => $this->recordings->pendingCount(),
|
||||||
fn (array $agent) => $agent + ['pending_recordings' => $pendingCounts[(int) $agent['id']] ?? 0],
|
]);
|
||||||
$this->agents->all()
|
|
||||||
);
|
|
||||||
|
|
||||||
return Response::json(['agents' => $agents]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(Request $request): Response
|
public function register(Request $request): Response
|
||||||
|
|
|
||||||
|
|
@ -7,36 +7,31 @@ namespace Monitor\Api\Controller;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Monitor\Api\Http\Request;
|
use Monitor\Api\Http\Request;
|
||||||
use Monitor\Api\Http\Response;
|
use Monitor\Api\Http\Response;
|
||||||
use Monitor\Api\Repository\AgentRepository;
|
|
||||||
use Monitor\Api\Repository\VoiceRecordingRepository;
|
use Monitor\Api\Repository\VoiceRecordingRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #85: Sprachaufnahme-Warteschlange. Zwei Zugriffsarten mit unterschiedlicher
|
* Sprachaufnahme-Warteschlange. Zwei Zugriffsarten mit unterschiedlicher Berechtigung,
|
||||||
* Berechtigung, analog zu HostConfigController:
|
* analog zu HostConfigController:
|
||||||
*
|
*
|
||||||
* - Upload aus record.html (Handy-Browser eines Menschen, kein registrierter Agent) -
|
* - Upload aus record.html (Handy-Browser eines Menschen, kein registrierter Agent) -
|
||||||
* unauthentifiziert wie die uebrigen von der UI ausgeloesten Schreibvorgaenge auf oeffentlich
|
* unauthentifiziert wie die uebrigen von der UI ausgeloesten Schreibvorgaenge auf
|
||||||
* verlinkten Seiten; die Agenten-Id kommt als URL-Segment mit.
|
* oeffentlich verlinkten Seiten.
|
||||||
* - Abholen/Markieren durch den Agenten selbst - hinter dem bestehenden Agenten-API-Key,
|
* - Abholen/Markieren durch den Voice-Bot - hinter dem bestehenden Agenten-API-Key.
|
||||||
* mit Ownership-Check wie bei AgentController::activity() (ein Agent sieht/holt nur seine
|
*
|
||||||
* eigene Warteschlange).
|
* Die Warteschlange ist global, nicht an einen Agenten adressiert (siehe
|
||||||
|
* 007_voice_recordings_global.sql): es gibt keinen Ownership-Check mehr, jeder
|
||||||
|
* authentifizierte Agent sieht dieselbe Liste. Das ist bewusst so - welches Ticket in
|
||||||
|
* welchem Repo entsteht, entscheidet der abholende Bot anhand des Transkripts, und ein
|
||||||
|
* zweiter Bot mit eigenem Key waere sonst ohne Not ausgesperrt.
|
||||||
*/
|
*/
|
||||||
final class VoiceRecordingController
|
final class VoiceRecordingController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(private readonly VoiceRecordingRepository $recordings)
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function upload(Request $request): Response
|
||||||
|
{
|
||||||
$file = $request->files['audio'] ?? null;
|
$file = $request->files['audio'] ?? null;
|
||||||
if (!is_array($file) || !isset($file['tmp_name'], $file['error'])) {
|
if (!is_array($file) || !isset($file['tmp_name'], $file['error'])) {
|
||||||
return Response::error('Feld "audio" (Datei) ist erforderlich.', 422);
|
return Response::error('Feld "audio" (Datei) ist erforderlich.', 422);
|
||||||
|
|
@ -56,7 +51,7 @@ final class VoiceRecordingController
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$recording = $this->recordings->store($agentId, $contents, (string) ($file['type'] ?? ''));
|
$recording = $this->recordings->store($contents, (string) ($file['type'] ?? ''));
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
return Response::error($e->getMessage(), 422);
|
return Response::error($e->getMessage(), 422);
|
||||||
}
|
}
|
||||||
|
|
@ -64,20 +59,13 @@ final class VoiceRecordingController
|
||||||
return Response::json(['recording' => $recording], 201);
|
return Response::json(['recording' => $recording], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @param array<string, mixed> $authenticatedAgent */
|
||||||
* @param array<string, string> $params
|
public function index(Request $request, array $authenticatedAgent): Response
|
||||||
* @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;
|
$status = isset($request->query['status']) ? (string) $request->query['status'] : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$recordings = $this->recordings->listForAgent((int) $params['id'], $status);
|
$recordings = $this->recordings->listAll($status);
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
return Response::error($e->getMessage(), 400);
|
return Response::error($e->getMessage(), 400);
|
||||||
}
|
}
|
||||||
|
|
@ -95,9 +83,6 @@ final class VoiceRecordingController
|
||||||
if ($recording === null) {
|
if ($recording === null) {
|
||||||
return Response::error('Aufnahme nicht gefunden.', 404);
|
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']);
|
$bytes = $this->recordings->readAudio((int) $recording['id']);
|
||||||
if ($bytes === null) {
|
if ($bytes === null) {
|
||||||
|
|
@ -119,9 +104,6 @@ final class VoiceRecordingController
|
||||||
if ($recording === null) {
|
if ($recording === null) {
|
||||||
return Response::error('Aufnahme nicht gefunden.', 404);
|
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'] ?? '');
|
$status = (string) ($request->body['status'] ?? '');
|
||||||
if ($status !== 'done') {
|
if ($status !== 'done') {
|
||||||
|
|
|
||||||
37
api/src/Db/migrations/007_voice_recordings_global.sql
Normal file
37
api/src/Db/migrations/007_voice_recordings_global.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- Nachfolger von 006_voice_recordings.sql: Eine Sprachaufnahme wird keinem Agenten mehr
|
||||||
|
-- zugewiesen. Aufgenommen wird in eine einzige globale Warteschlange, aus der sich der
|
||||||
|
-- Voice-Bot bedient; wohin das daraus entstehende Ticket geht, entscheidet er selbst
|
||||||
|
-- anhand des im Transkript genannten Repos - nicht anhand einer Agent-Id (siehe API.md
|
||||||
|
-- "Sprachaufnahmen" und fds2610/Agent, bin/voice-recording-agent.sh).
|
||||||
|
--
|
||||||
|
-- Die Spalte agent_id faellt deshalb weg. SQLite kennt kein "ALTER TABLE ... DROP COLUMN"
|
||||||
|
-- in allen hier relevanten Versionen und der Fremdschluessel auf agents haengt ohnehin
|
||||||
|
-- daran, daher derselbe Tabellen-Neubau wie in 004_agent_role_ops.sql.
|
||||||
|
--
|
||||||
|
-- Bestehende Aufnahmen behalten ihren Status: was zum Zeitpunkt der Migration noch 'new'
|
||||||
|
-- ist (in Produktion eine Aufnahme in der OPs-Warteschlange, die dort nie jemand abholen
|
||||||
|
-- konnte), liegt danach in der globalen Warteschlange und wird vom Voice-Bot verarbeitet.
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE voice_recordings_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
storage_name TEXT NOT NULL UNIQUE,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'done')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
processed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO voice_recordings_new (id, storage_name, mime_type, size_bytes, status, created_at, processed_at)
|
||||||
|
SELECT id, storage_name, mime_type, size_bytes, status, created_at, processed_at
|
||||||
|
FROM voice_recordings;
|
||||||
|
|
||||||
|
DROP TABLE voice_recordings;
|
||||||
|
ALTER TABLE voice_recordings_new RENAME TO voice_recordings;
|
||||||
|
|
||||||
|
-- Bedient den Abhol-Endpoint (GET /api/v1/recordings?status=new, aelteste zuerst) und die
|
||||||
|
-- Zaehlung offener Aufnahmen fuer den Schreibtisch-Zaehler des Voice-Bots.
|
||||||
|
CREATE INDEX idx_voice_recordings_status ON voice_recordings (status, created_at);
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
33
api/src/Db/migrations/008_agent_role_voice.sql
Normal file
33
api/src/Db/migrations/008_agent_role_voice.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- Neue Agenten-Rolle "voice" fuer den Voice-Bot: den Bot, der die globale
|
||||||
|
-- Sprachaufnahme-Warteschlange (007_voice_recordings_global.sql) abholt, transkribiert und
|
||||||
|
-- daraus Tickets anlegt. Er bekommt wie "ops" einen festen Schreibtisch, an dem das
|
||||||
|
-- Frontend den Zaehler offener Aufnahmen zeigt; nur seine Figur verlinkt auf record.html.
|
||||||
|
--
|
||||||
|
-- Tabellen-Neubau aus demselben Grund wie in 004_agent_role_ops.sql: SQLite kann den
|
||||||
|
-- CHECK-Constraint aus 001_initial.sql nicht nachtraeglich erweitern, und ein Edit der
|
||||||
|
-- alten Migration erreicht bestehende Installationen nicht mehr.
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
CREATE TABLE agents_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('entwickler', 'product_owner', 'qa', 'ops', 'voice', 'user')),
|
||||||
|
api_key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
desk_index INTEGER,
|
||||||
|
state TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
current_ticket_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
FOREIGN KEY (current_ticket_id) REFERENCES tickets (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO agents_new (id, display_name, role, api_key_hash, desk_index, state,
|
||||||
|
current_ticket_id, created_at, last_seen_at)
|
||||||
|
SELECT id, display_name, role, api_key_hash, desk_index, state,
|
||||||
|
current_ticket_id, created_at, last_seen_at
|
||||||
|
FROM agents;
|
||||||
|
|
||||||
|
DROP TABLE agents;
|
||||||
|
ALTER TABLE agents_new RENAME TO agents;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
@ -12,7 +12,7 @@ use Monitor\Api\Http\NotFoundException;
|
||||||
final class AgentRepository
|
final class AgentRepository
|
||||||
{
|
{
|
||||||
/** @var string[] */
|
/** @var string[] */
|
||||||
public const ROLES = ['entwickler', 'product_owner', 'qa', 'ops', 'user'];
|
public const ROLES = ['entwickler', 'product_owner', 'qa', 'ops', 'voice', 'user'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Praesenz-Timeout (Issue #17/#20): ein Agent gilt nur noch als anwesend, solange
|
* Praesenz-Timeout (Issue #17/#20): ein Agent gilt nur noch als anwesend, solange
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,17 @@ use InvalidArgumentException;
|
||||||
use Monitor\Api\Db\Database;
|
use Monitor\Api\Db\Database;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Warteschlange fuer Sprachaufnahmen (Issue #85): record.html laedt eine Aufnahme fuer einen
|
* Eine globale Warteschlange fuer Sprachaufnahmen: record.html laedt eine Aufnahme hoch, der
|
||||||
* Agenten hoch, der Agent fragt regelmaessig ab, ob Aufnahmen vorliegen, holt sie ab und
|
* Voice-Bot (Rolle "voice", siehe fds2610/Agent, bin/voice-recording-agent.sh) fragt
|
||||||
* markiert sie danach als erledigt (siehe fds2610/Agent#17 fuer die Agenten-Seite). Die
|
* regelmaessig ab, ob Aufnahmen vorliegen, holt sie ab und markiert sie danach als erledigt.
|
||||||
* eigentlichen Audiodateien liegen nicht in SQLite, sondern unter $storageDir - hier steht
|
*
|
||||||
* nur ihr Dateiname (storage_name) plus Metadaten.
|
* Bewusst ohne Zuordnung zu einem Agenten (bis 006_voice_recordings.sql gab es eine): welches
|
||||||
|
* Ticket wo entsteht, entscheidet der Bot anhand des im Transkript genannten Repos. Eine
|
||||||
|
* Agent-Id in der Warteschlange haette nur eine Zustaendigkeit vorgetaeuscht, die es nicht
|
||||||
|
* gibt - und Aufnahmen an Agenten adressierbar gemacht, die gar keine abholen.
|
||||||
|
*
|
||||||
|
* Die eigentlichen Audiodateien liegen nicht in SQLite, sondern unter $storageDir - hier
|
||||||
|
* steht nur ihr Dateiname (storage_name) plus Metadaten.
|
||||||
*/
|
*/
|
||||||
final class VoiceRecordingRepository
|
final class VoiceRecordingRepository
|
||||||
{
|
{
|
||||||
|
|
@ -44,7 +50,7 @@ final class VoiceRecordingRepository
|
||||||
* @return array<string, mixed> das neu angelegte Aufnahme-Objekt, ohne storage_name (siehe
|
* @return array<string, mixed> das neu angelegte Aufnahme-Objekt, ohne storage_name (siehe
|
||||||
* toPublic()).
|
* toPublic()).
|
||||||
*/
|
*/
|
||||||
public function store(int $agentId, string $contents, string $rawMimeType): array
|
public function store(string $contents, string $rawMimeType): array
|
||||||
{
|
{
|
||||||
$sizeBytes = strlen($contents);
|
$sizeBytes = strlen($contents);
|
||||||
if ($sizeBytes === 0) {
|
if ($sizeBytes === 0) {
|
||||||
|
|
@ -73,11 +79,10 @@ final class VoiceRecordingRepository
|
||||||
|
|
||||||
$now = $this->db->now();
|
$now = $this->db->now();
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->db->pdo()->prepare(
|
||||||
'INSERT INTO voice_recordings (agent_id, storage_name, mime_type, size_bytes, status, created_at)
|
'INSERT INTO voice_recordings (storage_name, mime_type, size_bytes, status, created_at)
|
||||||
VALUES (:agent_id, :storage_name, :mime_type, :size_bytes, :status, :created_at)'
|
VALUES (:storage_name, :mime_type, :size_bytes, :status, :created_at)'
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
'agent_id' => $agentId,
|
|
||||||
'storage_name' => $storageName,
|
'storage_name' => $storageName,
|
||||||
'mime_type' => $mimeType,
|
'mime_type' => $mimeType,
|
||||||
'size_bytes' => $sizeBytes,
|
'size_bytes' => $sizeBytes,
|
||||||
|
|
@ -88,18 +93,23 @@ final class VoiceRecordingRepository
|
||||||
return $this->toPublic($this->findRawOrFail((int) $this->db->pdo()->lastInsertId()));
|
return $this->toPublic($this->findRawOrFail((int) $this->db->pdo()->lastInsertId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, array<string, mixed>> */
|
/**
|
||||||
public function listForAgent(int $agentId, ?string $status = null): array
|
* Die gesamte Warteschlange, aelteste zuerst - der Voice-Bot arbeitet sie in dieser
|
||||||
|
* Reihenfolge ab.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listAll(?string $status = null): array
|
||||||
{
|
{
|
||||||
if ($status !== null && !in_array($status, ['new', 'done'], true)) {
|
if ($status !== null && !in_array($status, ['new', 'done'], true)) {
|
||||||
throw new InvalidArgumentException('Unbekannter Status: ' . $status);
|
throw new InvalidArgumentException('Unbekannter Status: ' . $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sql = 'SELECT id, agent_id, mime_type, size_bytes, status, created_at, processed_at
|
$sql = 'SELECT id, mime_type, size_bytes, status, created_at, processed_at
|
||||||
FROM voice_recordings WHERE agent_id = :agent_id';
|
FROM voice_recordings';
|
||||||
$params = ['agent_id' => $agentId];
|
$params = [];
|
||||||
if ($status !== null) {
|
if ($status !== null) {
|
||||||
$sql .= ' AND status = :status';
|
$sql .= ' WHERE status = :status';
|
||||||
$params['status'] = $status;
|
$params['status'] = $status;
|
||||||
}
|
}
|
||||||
$sql .= ' ORDER BY created_at ASC';
|
$sql .= ' ORDER BY created_at ASC';
|
||||||
|
|
@ -110,11 +120,11 @@ final class VoiceRecordingRepository
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fuer Controller-Ownership-Checks (download/markProcessed) - enthaelt bewusst storage_name. */
|
/** Fuer download/markProcessed im Controller - enthaelt bewusst storage_name. */
|
||||||
public function findById(int $id): ?array
|
public function findById(int $id): ?array
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->prepare(
|
$stmt = $this->db->pdo()->prepare(
|
||||||
'SELECT id, agent_id, storage_name, mime_type, size_bytes, status, created_at, processed_at
|
'SELECT id, storage_name, mime_type, size_bytes, status, created_at, processed_at
|
||||||
FROM voice_recordings WHERE id = :id'
|
FROM voice_recordings WHERE id = :id'
|
||||||
);
|
);
|
||||||
$stmt->execute(['id' => $id]);
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
@ -152,23 +162,17 @@ final class VoiceRecordingRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anzahl offener (status='new') Aufnahmen je Agent - Grundlage fuer den Zaehler auf dem
|
* Anzahl offener (status='new') Aufnahmen in der Warteschlange - Grundlage fuer den
|
||||||
* Schreibtisch (GET /api/v1/agents, siehe AgentController::index()).
|
* Zaehler am Schreibtisch des Voice-Bots (GET /api/v1/agents, siehe
|
||||||
*
|
* AgentController::index()).
|
||||||
* @return array<int, int> Agent-Id => Anzahl.
|
|
||||||
*/
|
*/
|
||||||
public function pendingCountsByAgent(): array
|
public function pendingCount(): int
|
||||||
{
|
{
|
||||||
$stmt = $this->db->pdo()->query(
|
$row = $this->db->pdo()->query(
|
||||||
"SELECT agent_id, COUNT(*) AS count FROM voice_recordings WHERE status = 'new' GROUP BY agent_id"
|
"SELECT COUNT(*) AS count FROM voice_recordings WHERE status = 'new'"
|
||||||
);
|
)->fetch();
|
||||||
|
|
||||||
$counts = [];
|
return (int) $row['count'];
|
||||||
foreach ($stmt->fetchAll() as $row) {
|
|
||||||
$counts[(int) $row['agent_id']] = (int) $row['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $counts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
|
/** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */
|
||||||
|
|
|
||||||
|
|
@ -308,9 +308,9 @@ final class ApplicationTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #85: voller Ablauf einer Sprachaufnahme - Upload aus record.html (unauthentifiziert,
|
* Voller Ablauf einer Sprachaufnahme - Upload aus record.html (unauthentifiziert, ohne
|
||||||
* Agenten-Id per URL), Abholen/Herunterladen/Markieren durch den Agenten selbst (hinter
|
* Agenten-Id), Abholen/Herunterladen/Markieren durch den Voice-Bot (hinter seinem
|
||||||
* seinem eigenen X-Api-Key).
|
* X-Api-Key).
|
||||||
*/
|
*/
|
||||||
public function testVoiceRecordingFullFlow(): void
|
public function testVoiceRecordingFullFlow(): void
|
||||||
{
|
{
|
||||||
|
|
@ -324,18 +324,17 @@ final class ApplicationTest extends TestCase
|
||||||
$registerResponse = $app->handle(Request::create(
|
$registerResponse = $app->handle(Request::create(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/v1/agents',
|
'/api/v1/agents',
|
||||||
body: ['display_name' => 'Alice', 'role' => 'entwickler'],
|
body: ['display_name' => 'Vox', 'role' => 'voice'],
|
||||||
headers: ['X-Admin-Key' => 'admin-secret']
|
headers: ['X-Admin-Key' => 'admin-secret']
|
||||||
));
|
));
|
||||||
$apiKey = $registerResponse->body()['api_key'];
|
$apiKey = $registerResponse->body()['api_key'];
|
||||||
$agentId = $registerResponse->body()['agent']['id'];
|
|
||||||
|
|
||||||
$tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-' . uniqid();
|
$tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-' . uniqid();
|
||||||
file_put_contents($tmpUploadFile, 'echte audiodaten');
|
file_put_contents($tmpUploadFile, 'echte audiodaten');
|
||||||
|
|
||||||
$uploadResponse = $app->handle(Request::create(
|
$uploadResponse = $app->handle(Request::create(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/v1/agents/' . $agentId . '/recordings',
|
'/api/v1/recordings',
|
||||||
files: ['audio' => [
|
files: ['audio' => [
|
||||||
'name' => 'aufnahme.webm',
|
'name' => 'aufnahme.webm',
|
||||||
'type' => 'audio/webm',
|
'type' => 'audio/webm',
|
||||||
|
|
@ -348,11 +347,11 @@ final class ApplicationTest extends TestCase
|
||||||
$recordingId = $uploadResponse->body()['recording']['id'];
|
$recordingId = $uploadResponse->body()['recording']['id'];
|
||||||
|
|
||||||
$agentsResponse = $app->handle(Request::create('GET', '/api/v1/agents'));
|
$agentsResponse = $app->handle(Request::create('GET', '/api/v1/agents'));
|
||||||
self::assertSame(1, $agentsResponse->body()['agents'][0]['pending_recordings']);
|
self::assertSame(1, $agentsResponse->body()['pending_recordings']);
|
||||||
|
|
||||||
$listResponse = $app->handle(Request::create(
|
$listResponse = $app->handle(Request::create(
|
||||||
'GET',
|
'GET',
|
||||||
'/api/v1/agents/' . $agentId . '/recordings',
|
'/api/v1/recordings',
|
||||||
query: ['status' => 'new'],
|
query: ['status' => 'new'],
|
||||||
headers: ['X-Api-Key' => $apiKey]
|
headers: ['X-Api-Key' => $apiKey]
|
||||||
));
|
));
|
||||||
|
|
@ -377,7 +376,7 @@ final class ApplicationTest extends TestCase
|
||||||
self::assertSame('done', $markResponse->body()['recording']['status']);
|
self::assertSame('done', $markResponse->body()['recording']['status']);
|
||||||
|
|
||||||
$agentsAfterResponse = $app->handle(Request::create('GET', '/api/v1/agents'));
|
$agentsAfterResponse = $app->handle(Request::create('GET', '/api/v1/agents'));
|
||||||
self::assertSame(0, $agentsAfterResponse->body()['agents'][0]['pending_recordings']);
|
self::assertSame(0, $agentsAfterResponse->body()['pending_recordings']);
|
||||||
|
|
||||||
unlink($tmpUploadFile);
|
unlink($tmpUploadFile);
|
||||||
foreach (glob($storageDir . '/*') ?: [] as $file) {
|
foreach (glob($storageDir . '/*') ?: [] as $file) {
|
||||||
|
|
@ -386,20 +385,79 @@ final class ApplicationTest extends TestCase
|
||||||
rmdir($storageDir);
|
rmdir($storageDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVoiceRecordingUploadForUnknownAgentReturns404(): void
|
public function testVoiceRecordingUploadWithoutFileReturns422(): void
|
||||||
{
|
{
|
||||||
$response = $this->app->handle(Request::create('POST', '/api/v1/agents/999/recordings'));
|
$response = $this->app->handle(Request::create('POST', '/api/v1/recordings'));
|
||||||
|
|
||||||
self::assertSame(404, $response->status);
|
self::assertSame(422, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testVoiceRecordingListRequiresAgentKey(): void
|
public function testVoiceRecordingListRequiresAgentKey(): void
|
||||||
{
|
{
|
||||||
$response = $this->app->handle(Request::create('GET', '/api/v1/agents/1/recordings'));
|
$response = $this->app->handle(Request::create('GET', '/api/v1/recordings'));
|
||||||
|
|
||||||
self::assertSame(401, $response->status);
|
self::assertSame(401, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Warteschlange gehoert keinem Agenten: ein zweiter Bot mit eigenem Key sieht
|
||||||
|
* dieselbe Liste. Frueher antwortete der Endpunkt hier mit 403, weil die Aufnahme an
|
||||||
|
* einen anderen Agenten adressiert war - genau daran blieben Aufnahmen haengen.
|
||||||
|
*/
|
||||||
|
public function testVoiceRecordingQueueIsSharedBetweenAgents(): void
|
||||||
|
{
|
||||||
|
$storageDir = sys_get_temp_dir() . '/monitor-app-recordings-shared-' . uniqid();
|
||||||
|
$app = new Application(Database::inMemory(), new Config([
|
||||||
|
'MONITOR_STATUS_FILE' => '/does/not/exist',
|
||||||
|
'MONITOR_ADMIN_API_KEY' => 'admin-secret',
|
||||||
|
'MONITOR_RECORDINGS_PATH' => $storageDir,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$otherAgent = $app->handle(Request::create(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/agents',
|
||||||
|
body: ['display_name' => 'OPs', 'role' => 'ops'],
|
||||||
|
headers: ['X-Admin-Key' => 'admin-secret']
|
||||||
|
));
|
||||||
|
$otherKey = $otherAgent->body()['api_key'];
|
||||||
|
|
||||||
|
$tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-shared-' . uniqid();
|
||||||
|
file_put_contents($tmpUploadFile, 'audio');
|
||||||
|
$uploadResponse = $app->handle(Request::create(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/recordings',
|
||||||
|
files: ['audio' => [
|
||||||
|
'name' => 'aufnahme.webm',
|
||||||
|
'type' => 'audio/webm',
|
||||||
|
'tmp_name' => $tmpUploadFile,
|
||||||
|
'error' => UPLOAD_ERR_OK,
|
||||||
|
'size' => filesize($tmpUploadFile),
|
||||||
|
]]
|
||||||
|
));
|
||||||
|
$recordingId = $uploadResponse->body()['recording']['id'];
|
||||||
|
|
||||||
|
$listResponse = $app->handle(Request::create(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/recordings',
|
||||||
|
headers: ['X-Api-Key' => $otherKey]
|
||||||
|
));
|
||||||
|
self::assertSame(200, $listResponse->status);
|
||||||
|
self::assertCount(1, $listResponse->body()['recordings']);
|
||||||
|
|
||||||
|
$downloadResponse = $app->handle(Request::create(
|
||||||
|
'GET',
|
||||||
|
'/api/v1/recordings/' . $recordingId . '/audio',
|
||||||
|
headers: ['X-Api-Key' => $otherKey]
|
||||||
|
));
|
||||||
|
self::assertSame(200, $downloadResponse->status);
|
||||||
|
|
||||||
|
unlink($tmpUploadFile);
|
||||||
|
foreach (glob($storageDir . '/*') ?: [] as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
rmdir($storageDir);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, zaehlt akzeptierte
|
* Hooks-KPI (Issue #38): oeffentlich wie die anderen GET-Endpunkte, zaehlt akzeptierte
|
||||||
* Webhook-Zustellungen unabhaengig vom Event-Typ.
|
* Webhook-Zustellungen unabhaengig vom Event-Typ.
|
||||||
|
|
|
||||||
|
|
@ -132,20 +132,20 @@ final class AgentControllerTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #85: der Zaehler auf dem Schreibtisch haengt an diesem Feld - GET /api/v1/agents
|
* Der Zaehler auf dem Schreibtisch des Voice-Bots haengt an diesem Feld - es steht neben
|
||||||
* reichert jeden Agenten um die Anzahl offener Sprachaufnahmen an.
|
* der Agentenliste, nicht an jedem Agenten: die Warteschlange ist global.
|
||||||
*/
|
*/
|
||||||
public function testIndexIncludesPendingRecordingsCount(): void
|
public function testIndexIncludesPendingRecordingsCount(): void
|
||||||
{
|
{
|
||||||
$registration = $this->repository->register('Alice', 'entwickler');
|
$this->repository->register('Vox', 'voice');
|
||||||
$agentId = (int) $registration['agent']['id'];
|
$done = $this->recordings->store('a', 'audio/webm');
|
||||||
$done = $this->recordings->store($agentId, 'a', 'audio/webm');
|
$this->recordings->store('b', 'audio/webm');
|
||||||
$this->recordings->store($agentId, 'b', 'audio/webm');
|
|
||||||
$this->recordings->markDone((int) $done['id']);
|
$this->recordings->markDone((int) $done['id']);
|
||||||
|
|
||||||
$response = $this->controller->index(Request::create('GET', '/api/v1/agents'));
|
$response = $this->controller->index(Request::create('GET', '/api/v1/agents'));
|
||||||
|
|
||||||
self::assertSame(1, $response->body()['agents'][0]['pending_recordings']);
|
self::assertSame(1, $response->body()['pending_recordings']);
|
||||||
|
self::assertArrayNotHasKey('pending_recordings', $response->body()['agents'][0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexDefaultsPendingRecordingsToZero(): void
|
public function testIndexDefaultsPendingRecordingsToZero(): void
|
||||||
|
|
@ -154,6 +154,6 @@ final class AgentControllerTest extends TestCase
|
||||||
|
|
||||||
$response = $this->controller->index(Request::create('GET', '/api/v1/agents'));
|
$response = $this->controller->index(Request::create('GET', '/api/v1/agents'));
|
||||||
|
|
||||||
self::assertSame(0, $response->body()['agents'][0]['pending_recordings']);
|
self::assertSame(0, $response->body()['pending_recordings']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ final class VoiceRecordingControllerTest extends TestCase
|
||||||
$this->agents = new AgentRepository($db);
|
$this->agents = new AgentRepository($db);
|
||||||
$this->storageDir = sys_get_temp_dir() . '/monitor-recordings-controller-' . uniqid();
|
$this->storageDir = sys_get_temp_dir() . '/monitor-recordings-controller-' . uniqid();
|
||||||
$this->recordings = new VoiceRecordingRepository($db, $this->storageDir);
|
$this->recordings = new VoiceRecordingRepository($db, $this->storageDir);
|
||||||
$this->controller = new VoiceRecordingController($this->recordings, $this->agents);
|
$this->controller = new VoiceRecordingController($this->recordings);
|
||||||
|
|
||||||
$this->tmpUploadFile = sys_get_temp_dir() . '/monitor-upload-' . uniqid();
|
$this->tmpUploadFile = sys_get_temp_dir() . '/monitor-upload-' . uniqid();
|
||||||
file_put_contents($this->tmpUploadFile, 'echte audiodaten');
|
file_put_contents($this->tmpUploadFile, 'echte audiodaten');
|
||||||
|
|
@ -56,106 +56,92 @@ final class VoiceRecordingControllerTest extends TestCase
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUploadRejectsUnknownAgent(): void
|
/** Der Voice-Bot: die Rolle, die die Warteschlange tatsaechlich abholt. */
|
||||||
|
private function voiceBot(): array
|
||||||
{
|
{
|
||||||
$response = $this->controller->upload(
|
return $this->agents->register('Vox', 'voice')['agent'];
|
||||||
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]),
|
|
||||||
['id' => '999']
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(404, $response->status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUploadRejectsMissingFile(): void
|
public function testUploadRejectsMissingFile(): void
|
||||||
{
|
{
|
||||||
$agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id'];
|
$response = $this->controller->upload(Request::create('POST', '/x'));
|
||||||
|
|
||||||
$response = $this->controller->upload(Request::create('POST', '/x'), ['id' => (string) $agentId]);
|
|
||||||
|
|
||||||
self::assertSame(422, $response->status);
|
self::assertSame(422, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUploadRejectsTooLargeUploadError(): void
|
public function testUploadRejectsTooLargeUploadError(): void
|
||||||
{
|
{
|
||||||
$agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id'];
|
|
||||||
|
|
||||||
$response = $this->controller->upload(
|
$response = $this->controller->upload(
|
||||||
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField(UPLOAD_ERR_INI_SIZE)]),
|
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField(UPLOAD_ERR_INI_SIZE)])
|
||||||
['id' => (string) $agentId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(413, $response->status);
|
self::assertSame(413, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUploadStoresRecordingForKnownAgent(): void
|
/**
|
||||||
|
* Der Upload kennt keinen Empfaenger mehr: kein Agent im Pfad, keine agent_id in der
|
||||||
|
* Antwort - die Aufnahme liegt danach in der Warteschlange fuer alle.
|
||||||
|
*/
|
||||||
|
public function testUploadStoresRecordingWithoutAgent(): void
|
||||||
{
|
{
|
||||||
$agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id'];
|
|
||||||
|
|
||||||
$response = $this->controller->upload(
|
$response = $this->controller->upload(
|
||||||
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]),
|
Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()])
|
||||||
['id' => (string) $agentId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(201, $response->status);
|
self::assertSame(201, $response->status);
|
||||||
self::assertSame($agentId, $response->body()['recording']['agent_id']);
|
self::assertArrayNotHasKey('agent_id', $response->body()['recording']);
|
||||||
self::assertSame('new', $response->body()['recording']['status']);
|
self::assertSame('new', $response->body()['recording']['status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexRejectsMismatchedAgentId(): void
|
public function testIndexListsTheSharedQueue(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
$this->recordings->store('a', 'audio/webm');
|
||||||
$other = $this->agents->register('Bob', 'qa');
|
|
||||||
|
|
||||||
$response = $this->controller->index(
|
$response = $this->controller->index(Request::create('GET', '/x'), $this->voiceBot());
|
||||||
Request::create('GET', '/x'),
|
|
||||||
['id' => (string) $other['agent']['id']],
|
|
||||||
$registration['agent']
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(403, $response->status);
|
self::assertSame(200, $response->status);
|
||||||
|
self::assertCount(1, $response->body()['recordings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexListsOwnRecordings(): void
|
public function testIndexFiltersByStatus(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
$done = $this->recordings->store('a', 'audio/webm');
|
||||||
$agentId = (int) $registration['agent']['id'];
|
$this->recordings->store('b', 'audio/webm');
|
||||||
$this->recordings->store($agentId, 'a', 'audio/webm');
|
$this->recordings->markDone((int) $done['id']);
|
||||||
|
|
||||||
$response = $this->controller->index(
|
$response = $this->controller->index(
|
||||||
Request::create('GET', '/x'),
|
Request::create('GET', '/x', query: ['status' => 'new']),
|
||||||
['id' => (string) $agentId],
|
$this->voiceBot()
|
||||||
$registration['agent']
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(200, $response->status);
|
self::assertSame(200, $response->status);
|
||||||
self::assertCount(1, $response->body()['recordings']);
|
self::assertCount(1, $response->body()['recordings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDownloadRejectsForeignAgent(): void
|
public function testIndexRejectsUnknownStatus(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
$response = $this->controller->index(
|
||||||
$other = $this->agents->register('Bob', 'qa');
|
Request::create('GET', '/x', query: ['status' => 'archived']),
|
||||||
$recording = $this->recordings->store((int) $other['agent']['id'], 'a', 'audio/webm');
|
$this->voiceBot()
|
||||||
|
|
||||||
$response = $this->controller->download(
|
|
||||||
Request::create('GET', '/x'),
|
|
||||||
['id' => (string) $recording['id']],
|
|
||||||
$registration['agent']
|
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(403, $response->status);
|
self::assertSame(400, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDownloadReturnsAudioBytesForOwnRecording(): void
|
/**
|
||||||
|
* Jeder authentifizierte Agent kommt an jede Aufnahme - frueher hing der Zugriff an der
|
||||||
|
* agent_id der Aufnahme, und genau daran blieb in Produktion eine Aufnahme haengen, die
|
||||||
|
* an einen Agenten ohne Whisper adressiert war.
|
||||||
|
*/
|
||||||
|
public function testDownloadReturnsAudioBytesForAnyAgent(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
$recording = $this->recordings->store('echte audiodaten', 'audio/webm');
|
||||||
$agentId = (int) $registration['agent']['id'];
|
$otherAgent = $this->agents->register('OPs', 'ops')['agent'];
|
||||||
$recording = $this->recordings->store($agentId, 'echte audiodaten', 'audio/webm');
|
|
||||||
|
|
||||||
$response = $this->controller->download(
|
$response = $this->controller->download(
|
||||||
Request::create('GET', '/x'),
|
Request::create('GET', '/x'),
|
||||||
['id' => (string) $recording['id']],
|
['id' => (string) $recording['id']],
|
||||||
$registration['agent']
|
$otherAgent
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(200, $response->status);
|
self::assertSame(200, $response->status);
|
||||||
|
|
@ -164,57 +150,47 @@ final class VoiceRecordingControllerTest extends TestCase
|
||||||
|
|
||||||
public function testDownloadReturns404ForUnknownRecording(): void
|
public function testDownloadReturns404ForUnknownRecording(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
|
||||||
|
|
||||||
$response = $this->controller->download(
|
$response = $this->controller->download(
|
||||||
Request::create('GET', '/x'),
|
Request::create('GET', '/x'),
|
||||||
['id' => '999'],
|
['id' => '999'],
|
||||||
$registration['agent']
|
$this->voiceBot()
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(404, $response->status);
|
self::assertSame(404, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMarkProcessedRejectsForeignAgent(): void
|
|
||||||
{
|
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
|
||||||
$other = $this->agents->register('Bob', 'qa');
|
|
||||||
$recording = $this->recordings->store((int) $other['agent']['id'], 'a', 'audio/webm');
|
|
||||||
|
|
||||||
$response = $this->controller->markProcessed(
|
|
||||||
Request::create('PATCH', '/x', body: ['status' => 'done']),
|
|
||||||
['id' => (string) $recording['id']],
|
|
||||||
$registration['agent']
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertSame(403, $response->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMarkProcessedRejectsUnsupportedStatus(): void
|
public function testMarkProcessedRejectsUnsupportedStatus(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
$recording = $this->recordings->store('a', 'audio/webm');
|
||||||
$agentId = (int) $registration['agent']['id'];
|
|
||||||
$recording = $this->recordings->store($agentId, 'a', 'audio/webm');
|
|
||||||
|
|
||||||
$response = $this->controller->markProcessed(
|
$response = $this->controller->markProcessed(
|
||||||
Request::create('PATCH', '/x', body: ['status' => 'archived']),
|
Request::create('PATCH', '/x', body: ['status' => 'archived']),
|
||||||
['id' => (string) $recording['id']],
|
['id' => (string) $recording['id']],
|
||||||
$registration['agent']
|
$this->voiceBot()
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(422, $response->status);
|
self::assertSame(422, $response->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMarkProcessedMarksOwnRecordingDone(): void
|
public function testMarkProcessedReturns404ForUnknownRecording(): void
|
||||||
{
|
{
|
||||||
$registration = $this->agents->register('Alice', 'entwickler');
|
$response = $this->controller->markProcessed(
|
||||||
$agentId = (int) $registration['agent']['id'];
|
Request::create('PATCH', '/x', body: ['status' => 'done']),
|
||||||
$recording = $this->recordings->store($agentId, 'a', 'audio/webm');
|
['id' => '999'],
|
||||||
|
$this->voiceBot()
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(404, $response->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMarkProcessedMarksRecordingDone(): void
|
||||||
|
{
|
||||||
|
$recording = $this->recordings->store('a', 'audio/webm');
|
||||||
|
|
||||||
$response = $this->controller->markProcessed(
|
$response = $this->controller->markProcessed(
|
||||||
Request::create('PATCH', '/x', body: ['status' => 'done']),
|
Request::create('PATCH', '/x', body: ['status' => 'done']),
|
||||||
['id' => (string) $recording['id']],
|
['id' => (string) $recording['id']],
|
||||||
$registration['agent']
|
$this->voiceBot()
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(200, $response->status);
|
self::assertSame(200, $response->status);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ namespace Monitor\Api\Tests\Repository;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Monitor\Api\Db\Database;
|
use Monitor\Api\Db\Database;
|
||||||
use Monitor\Api\Repository\AgentRepository;
|
|
||||||
use Monitor\Api\Repository\VoiceRecordingRepository;
|
use Monitor\Api\Repository\VoiceRecordingRepository;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
|
@ -14,14 +13,10 @@ final class VoiceRecordingRepositoryTest extends TestCase
|
||||||
{
|
{
|
||||||
private VoiceRecordingRepository $recordings;
|
private VoiceRecordingRepository $recordings;
|
||||||
private string $storageDir;
|
private string $storageDir;
|
||||||
private int $agentId;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$db = Database::inMemory();
|
$db = Database::inMemory();
|
||||||
$agents = new AgentRepository($db);
|
|
||||||
$this->agentId = (int) $agents->register('Alice', 'entwickler')['agent']['id'];
|
|
||||||
|
|
||||||
$this->storageDir = sys_get_temp_dir() . '/monitor-recordings-' . uniqid();
|
$this->storageDir = sys_get_temp_dir() . '/monitor-recordings-' . uniqid();
|
||||||
$this->recordings = new VoiceRecordingRepository($db, $this->storageDir, 1024);
|
$this->recordings = new VoiceRecordingRepository($db, $this->storageDir, 1024);
|
||||||
}
|
}
|
||||||
|
|
@ -40,62 +35,62 @@ final class VoiceRecordingRepositoryTest extends TestCase
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
$this->recordings->store($this->agentId, '', 'audio/webm');
|
$this->recordings->store('', 'audio/webm');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStoreRejectsFilesAboveTheLimit(): void
|
public function testStoreRejectsFilesAboveTheLimit(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
$this->recordings->store($this->agentId, str_repeat('x', 2000), 'audio/webm');
|
$this->recordings->store(str_repeat('x', 2000), 'audio/webm');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStoreRejectsUnsupportedMimeTypes(): void
|
public function testStoreRejectsUnsupportedMimeTypes(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
$this->recordings->store($this->agentId, 'daten', 'application/octet-stream');
|
$this->recordings->store('daten', 'application/octet-stream');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStoreAcceptsMimeTypeWithCodecsParameter(): void
|
public function testStoreAcceptsMimeTypeWithCodecsParameter(): void
|
||||||
{
|
{
|
||||||
$recording = $this->recordings->store($this->agentId, 'daten', 'audio/webm;codecs=opus');
|
$recording = $this->recordings->store('daten', 'audio/webm;codecs=opus');
|
||||||
|
|
||||||
self::assertSame('audio/webm', $recording['mime_type']);
|
self::assertSame('audio/webm', $recording['mime_type']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testStoreWritesFileAndDbRowAndHidesStorageName(): void
|
public function testStoreWritesFileAndDbRowAndHidesStorageName(): void
|
||||||
{
|
{
|
||||||
$recording = $this->recordings->store($this->agentId, 'echte audiodaten', 'audio/webm');
|
$recording = $this->recordings->store('echte audiodaten', 'audio/webm');
|
||||||
|
|
||||||
self::assertArrayNotHasKey('storage_name', $recording);
|
self::assertArrayNotHasKey('storage_name', $recording);
|
||||||
self::assertSame($this->agentId, $recording['agent_id']);
|
self::assertArrayNotHasKey('agent_id', $recording);
|
||||||
self::assertSame('new', $recording['status']);
|
self::assertSame('new', $recording['status']);
|
||||||
self::assertSame(strlen('echte audiodaten'), $recording['size_bytes']);
|
self::assertSame(strlen('echte audiodaten'), $recording['size_bytes']);
|
||||||
self::assertSame('echte audiodaten', $this->recordings->readAudio((int) $recording['id']));
|
self::assertSame('echte audiodaten', $this->recordings->readAudio((int) $recording['id']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testListForAgentFiltersByStatus(): void
|
public function testListAllFiltersByStatus(): void
|
||||||
{
|
{
|
||||||
$recording = $this->recordings->store($this->agentId, 'a', 'audio/ogg');
|
$recording = $this->recordings->store('a', 'audio/ogg');
|
||||||
$this->recordings->store($this->agentId, 'b', 'audio/ogg');
|
$this->recordings->store('b', 'audio/ogg');
|
||||||
$this->recordings->markDone((int) $recording['id']);
|
$this->recordings->markDone((int) $recording['id']);
|
||||||
|
|
||||||
self::assertCount(1, $this->recordings->listForAgent($this->agentId, 'new'));
|
self::assertCount(1, $this->recordings->listAll('new'));
|
||||||
self::assertCount(1, $this->recordings->listForAgent($this->agentId, 'done'));
|
self::assertCount(1, $this->recordings->listAll('done'));
|
||||||
self::assertCount(2, $this->recordings->listForAgent($this->agentId));
|
self::assertCount(2, $this->recordings->listAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testListForAgentRejectsUnknownStatus(): void
|
public function testListAllRejectsUnknownStatus(): void
|
||||||
{
|
{
|
||||||
$this->expectException(InvalidArgumentException::class);
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
|
||||||
$this->recordings->listForAgent($this->agentId, 'archived');
|
$this->recordings->listAll('archived');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testMarkDoneSetsProcessedAt(): void
|
public function testMarkDoneSetsProcessedAt(): void
|
||||||
{
|
{
|
||||||
$recording = $this->recordings->store($this->agentId, 'a', 'audio/wav');
|
$recording = $this->recordings->store('a', 'audio/wav');
|
||||||
|
|
||||||
$updated = $this->recordings->markDone((int) $recording['id']);
|
$updated = $this->recordings->markDone((int) $recording['id']);
|
||||||
|
|
||||||
|
|
@ -103,13 +98,13 @@ final class VoiceRecordingRepositoryTest extends TestCase
|
||||||
self::assertNotNull($updated['processed_at']);
|
self::assertNotNull($updated['processed_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPendingCountsByAgentCountsOnlyNewRecordings(): void
|
public function testPendingCountCountsOnlyNewRecordings(): void
|
||||||
{
|
{
|
||||||
$first = $this->recordings->store($this->agentId, 'a', 'audio/mpeg');
|
$first = $this->recordings->store('a', 'audio/mpeg');
|
||||||
$this->recordings->store($this->agentId, 'b', 'audio/mpeg');
|
$this->recordings->store('b', 'audio/mpeg');
|
||||||
$this->recordings->markDone((int) $first['id']);
|
$this->recordings->markDone((int) $first['id']);
|
||||||
|
|
||||||
self::assertSame([$this->agentId => 1], $this->recordings->pendingCountsByAgent());
|
self::assertSame(1, $this->recordings->pendingCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testReadAudioReturnsNullForUnknownId(): void
|
public function testReadAudioReturnsNullForUnknownId(): void
|
||||||
|
|
@ -120,7 +115,7 @@ final class VoiceRecordingRepositoryTest extends TestCase
|
||||||
public function testFingerprintChangesWhenARecordingIsAdded(): void
|
public function testFingerprintChangesWhenARecordingIsAdded(): void
|
||||||
{
|
{
|
||||||
$before = $this->recordings->fingerprint();
|
$before = $this->recordings->fingerprint();
|
||||||
$this->recordings->store($this->agentId, 'a', 'audio/webm');
|
$this->recordings->store('a', 'audio/webm');
|
||||||
|
|
||||||
self::assertNotSame($before, $this->recordings->fingerprint());
|
self::assertNotSame($before, $this->recordings->fingerprint());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,19 @@ header.office-header h1 {
|
||||||
fill: #ede9fe !important;
|
fill: #ede9fe !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mikrofon am Schreibtisch des Voice-Bots: gefuellte Kapsel, Buegel und Fuss nur als Strich -
|
||||||
|
sonst wuerde der Halbkreis als Flaeche zulaufen. */
|
||||||
|
.desk-deco-mic-capsule {
|
||||||
|
fill: #5eead4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-deco-mic-bracket {
|
||||||
|
fill: none;
|
||||||
|
stroke: #5eead4;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
.desk-empty .desk-chair {
|
.desk-empty .desk-chair {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ body {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Der Hinweis, dass das Projekt am Anfang genannt werden soll - leiser als die Zeile mit dem
|
||||||
|
abholenden Bot darueber, aber vor dem Start-Knopf sichtbar. */
|
||||||
|
.record-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.record-main {
|
.record-main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
|
|
||||||
|
|
@ -48,43 +48,51 @@ export async function fetchKpis() {
|
||||||
return kpis;
|
return kpis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Agentenliste zusammen mit der Anzahl offener Sprachaufnahmen. Die Warteschlange
|
||||||
|
* gehoert keinem Agenten mehr, deshalb steht der Zaehler neben der Liste und nicht an jedem
|
||||||
|
* Agenten (siehe API.md "Agenten").
|
||||||
|
*
|
||||||
|
* @returns {Promise<{agents: Array<object>, pendingRecordings: number}>}
|
||||||
|
*/
|
||||||
export async function fetchAgents() {
|
export async function fetchAgents() {
|
||||||
const { agents } = await getJson("/agents");
|
const { agents, pending_recordings: pendingRecordings } = await getJson("/agents");
|
||||||
return agents;
|
return { agents, pendingRecordings: pendingRecordings ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue #85: record.html zeigt den Namen des Agenten, fuer den gerade aufgenommen wird - holt
|
* Der Voice-Bot: der Agent, der die Sprachaufnahmen abholt. record.html nennt ihn beim Namen,
|
||||||
* dafuer einfach die volle (kleine) Agentenliste und filtert lokal, ein eigener
|
* damit erkennbar ist, wer die Aufnahme abarbeitet - und meldet, wenn gar keiner registriert
|
||||||
* GET-/agents/{id}-Endpunkt existiert nicht.
|
* ist, weil die Aufnahme dann unbearbeitet liegen bleibt.
|
||||||
*/
|
*/
|
||||||
export async function fetchAgent(agentId) {
|
export async function fetchVoiceAgent() {
|
||||||
const agents = await fetchAgents();
|
const { agents } = await fetchAgents();
|
||||||
return agents.find((agent) => String(agent.id) === String(agentId)) ?? null;
|
return agents.find((agent) => agent.role === "voice") ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verlinkt von der Buero-Szene aus (Klick auf die Personen-Figur eines registrierten Agenten,
|
* Verlinkt von der Buero-Szene aus (Klick auf die Figur des Voice-Bots) auf die
|
||||||
* Issue #85) auf die Aufnahme-Webseite. record.html liegt neben office.html im selben
|
* Aufnahme-Webseite. Ohne Agent-Parameter: die Aufnahme landet in einer Warteschlange fuer
|
||||||
* Verzeichnis, daher ein rein relativer Pfad statt ueber apiBase() (record.html spricht die
|
* alle. record.html liegt neben office.html im selben Verzeichnis, daher ein rein relativer
|
||||||
* API zwar auch an, ist selbst aber keine API-Route).
|
* Pfad statt ueber apiBase() (record.html spricht die API zwar auch an, ist selbst aber keine
|
||||||
|
* API-Route).
|
||||||
*/
|
*/
|
||||||
export function recordingUrl(agentId) {
|
export function recordingUrl() {
|
||||||
return `record.html?agent=${encodeURIComponent(agentId)}`;
|
return "record.html";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Laedt eine im Browser aufgenommene Sprachnotiz hoch (Issue #85). Unauthentifiziert wie der
|
* Laedt eine im Browser aufgenommene Sprachnotiz hoch. Unauthentifiziert wie der
|
||||||
* Endpunkt selbst - record.html laeuft im Handy-Browser eines Menschen, nicht eines
|
* Endpunkt selbst - record.html laeuft im Handy-Browser eines Menschen, nicht eines
|
||||||
* registrierten Agenten, ohne Api-/Admin-Key. FormData statt JSON, weil die Audiodatei ein
|
* registrierten Agenten, ohne Api-/Admin-Key. FormData statt JSON, weil die Audiodatei ein
|
||||||
* Blob ist; der Browser setzt den multipart-Content-Type inkl. Boundary selbst, ein manuell
|
* Blob ist; der Browser setzt den multipart-Content-Type inkl. Boundary selbst, ein manuell
|
||||||
* gesetzter Header wuerde die Boundary kaputt machen.
|
* gesetzter Header wuerde die Boundary kaputt machen.
|
||||||
*/
|
*/
|
||||||
export async function uploadRecording(agentId, blob, filename) {
|
export async function uploadRecording(blob, filename) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("audio", blob, filename);
|
formData.append("audio", blob, filename);
|
||||||
|
|
||||||
const response = await fetch(`${apiBase()}/agents/${encodeURIComponent(agentId)}/recordings`, {
|
const response = await fetch(`${apiBase()}/recordings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -663,9 +663,10 @@ function renderDeskAt(origin, agent, width = DESK.w) {
|
||||||
);
|
);
|
||||||
g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
|
g.appendChild(el("circle", { cx: width / 2, cy: 40 + DESK.h + 30, r: 22, class: "desk-chair" }));
|
||||||
|
|
||||||
// Issue #85: Zaehler offener Sprachaufnahmen am Schreibtisch dieses Agenten - oben links,
|
// Zaehler offener Sprachaufnahmen - oben links, damit er nicht mit der rollenabhaengigen
|
||||||
// damit er nicht mit der rollenabhaengigen Deko (star/flower/check/gear) oben rechts
|
// Deko (star/flower/check/gear/mic) oben rechts kollidiert. Nur sichtbar, wenn tatsaechlich
|
||||||
// kollidiert. Nur sichtbar, wenn tatsaechlich etwas in der Warteschlange liegt.
|
// etwas in der Warteschlange liegt. Gefuellt wird das Feld nur beim Voice-Bot (siehe
|
||||||
|
// renderOfficeScene): die Warteschlange ist global, er ist der Einzige, der sie abholt.
|
||||||
if (agent && agent.pending_recordings > 0) {
|
if (agent && agent.pending_recordings > 0) {
|
||||||
g.appendChild(renderRecordingBadge(14, 4, agent.pending_recordings));
|
g.appendChild(renderRecordingBadge(14, 4, agent.pending_recordings));
|
||||||
}
|
}
|
||||||
|
|
@ -718,6 +719,18 @@ function renderDeskAt(origin, agent, width = DESK.w) {
|
||||||
el("circle", { cx, cy, r: 3.5, class: "desk-deco-gear-center" }),
|
el("circle", { cx, cy, r: 3.5, class: "desk-deco-gear-center" }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
} else if (role.deco === "mic") {
|
||||||
|
// Mikrofon = Kapsel, darunter ein Buegel auf einem kurzen Standfuss. Gleiche Groesse
|
||||||
|
// und Position wie das Zahnrad, damit alle Deko-Varianten denselben Platz belegen.
|
||||||
|
const cx = width - 45;
|
||||||
|
const cy = 16;
|
||||||
|
g.appendChild(
|
||||||
|
el("g", { class: "desk-deco desk-deco-mic" }, [
|
||||||
|
el("rect", { x: cx - 4, y: cy - 8, width: 8, height: 16, rx: 4, class: "desk-deco-mic-capsule" }),
|
||||||
|
el("path", { d: `M${cx - 8},${cy + 4} a8,8 0 0 0 16,0`, class: "desk-deco-mic-bracket" }),
|
||||||
|
el("path", { d: `M${cx},${cy + 12} l0,5`, class: "desk-deco-mic-bracket" }),
|
||||||
|
])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blockierte Agenten faerben ihr Namensschild ein. Der Zustand steckt hier in der Pose, weil
|
// Blockierte Agenten faerben ihr Namensschild ein. Der Zustand steckt hier in der Pose, weil
|
||||||
|
|
@ -954,17 +967,18 @@ function updateAgentsLayer(layer, placements) {
|
||||||
const id = String(agent.id ?? `guest-${index}`);
|
const id = String(agent.id ?? `guest-${index}`);
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
|
|
||||||
// Echte registrierte Agenten haben eine numerische Id von der API; Demo-Agenten und die
|
// Nur die Figur des Voice-Bots fuehrt zur Aufnahmeseite: er ist der einzige, der die
|
||||||
// guest-N-Fallback-Id kennen keine Aufnahme-Warteschlange, sind also nicht klickbar.
|
// Warteschlange abholt. Frueher war jede Agentenfigur klickbar - dabei entstanden
|
||||||
const isRealAgent = typeof agent.id !== "undefined" && id !== PO_AGENT_ID;
|
// Aufnahmen in Warteschlangen, die nie jemand geleert hat.
|
||||||
|
const isVoiceBot = agent.role === "voice" && id !== PO_AGENT_ID;
|
||||||
|
|
||||||
let node = agentAvatarElements.get(id);
|
let node = agentAvatarElements.get(id);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
node = createAgentAvatarElement();
|
node = createAgentAvatarElement();
|
||||||
if (id === PO_AGENT_ID) {
|
if (id === PO_AGENT_ID) {
|
||||||
makePoAvatarClickable(node);
|
makePoAvatarClickable(node);
|
||||||
} else if (isRealAgent) {
|
} else if (isVoiceBot) {
|
||||||
makeAgentAvatarClickable(node, agent.id);
|
makeVoiceBotAvatarClickable(node);
|
||||||
}
|
}
|
||||||
agentAvatarElements.set(id, node);
|
agentAvatarElements.set(id, node);
|
||||||
layer.appendChild(node);
|
layer.appendChild(node);
|
||||||
|
|
@ -1000,17 +1014,16 @@ function makePoAvatarClickable(node) {
|
||||||
node.addEventListener("click", openRepoConfigModal);
|
node.addEventListener("click", openRepoConfigModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #85: ein Klick auf die Personen-Figur eines registrierten Agenten (nicht auf den
|
// Ein Klick auf die Figur des Voice-Bots oeffnet die Aufnahme-Webseite in einem neuen Tab -
|
||||||
// synthetischen PO-Platz, siehe makePoAvatarClickable) oeffnet die Aufnahme-Webseite fuer
|
// die laufende Buero-Szene bleibt dabei unangetastet stehen, genau wie ein Modal es waere,
|
||||||
// diesen Agenten in einem neuen Tab - die laufende Buero-Szene bleibt dabei unangetastet
|
// nur dass record.html eine eigene Seite ist (braucht Mikrofon-Zugriff und ist fuers Handy
|
||||||
// stehen, genau wie ein Modal es waere, nur dass record.html eine eigene Seite ist (braucht
|
// gedacht). Die Aufnahme geht in eine Warteschlange fuer alle, nicht an einen Agenten.
|
||||||
// Mikrofon-Zugriff und ist fuers Handy gedacht).
|
function makeVoiceBotAvatarClickable(node) {
|
||||||
function makeAgentAvatarClickable(node, agentId) {
|
|
||||||
const title = document.createElementNS(SVG_NS, "title");
|
const title = document.createElementNS(SVG_NS, "title");
|
||||||
title.textContent = "Sprachaufnahme fuer diesen Agenten aufnehmen";
|
title.textContent = "Sprachnotiz aufnehmen";
|
||||||
node.appendChild(title);
|
node.appendChild(title);
|
||||||
node.addEventListener("click", () => {
|
node.addEventListener("click", () => {
|
||||||
window.open(recordingUrl(agentId), "_blank", "noopener");
|
window.open(recordingUrl(), "_blank", "noopener");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1969,8 +1982,12 @@ async function loadHostConfigModal(token) {
|
||||||
let loaded = null;
|
let loaded = null;
|
||||||
let error = "";
|
let error = "";
|
||||||
try {
|
try {
|
||||||
const [hosts, agents, systems] = await Promise.all([fetchHostConfigs(), fetchAgents(), fetchSystems()]);
|
const [hosts, agentsResult, systems] = await Promise.all([
|
||||||
loaded = { hosts, agents, systems };
|
fetchHostConfigs(),
|
||||||
|
fetchAgents(),
|
||||||
|
fetchSystems(),
|
||||||
|
]);
|
||||||
|
loaded = { hosts, agents: agentsResult.agents, systems };
|
||||||
} catch {
|
} catch {
|
||||||
error = "Hosts konnten nicht geladen werden.";
|
error = "Hosts konnten nicht geladen werden.";
|
||||||
}
|
}
|
||||||
|
|
@ -2260,7 +2277,7 @@ async function handleSystemDelete(system) {
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
async function loadLiveData() {
|
async function loadLiveData() {
|
||||||
const [tickets, events, systems, kpis, agents, webhookStats] = await Promise.all([
|
const [tickets, events, systems, kpis, agentsResult, webhookStats] = await Promise.all([
|
||||||
fetchTickets(),
|
fetchTickets(),
|
||||||
fetchEvents(),
|
fetchEvents(),
|
||||||
fetchSystems(),
|
fetchSystems(),
|
||||||
|
|
@ -2268,7 +2285,16 @@ async function loadLiveData() {
|
||||||
fetchAgents(),
|
fetchAgents(),
|
||||||
fetchWebhookStats(),
|
fetchWebhookStats(),
|
||||||
]);
|
]);
|
||||||
return { tickets, events, systems, kpis, agents, hooksCount: webhookStats.count, isLive: true };
|
return {
|
||||||
|
tickets,
|
||||||
|
events,
|
||||||
|
systems,
|
||||||
|
kpis,
|
||||||
|
agents: agentsResult.agents,
|
||||||
|
pendingRecordings: agentsResult.pendingRecordings,
|
||||||
|
hooksCount: webhookStats.count,
|
||||||
|
isLive: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
|
const animationQueue = new AnimationQueue({ minIntervalMs: 120 });
|
||||||
|
|
@ -2291,6 +2317,7 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
kpis = [],
|
kpis = [],
|
||||||
tickets = [],
|
tickets = [],
|
||||||
hooksCount = null,
|
hooksCount = null,
|
||||||
|
pendingRecordings = 0,
|
||||||
isLive = false,
|
isLive = false,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
|
|
@ -2374,8 +2401,13 @@ export function renderOfficeScene(container, data = {}) {
|
||||||
// oben), soll sein Schreibtisch leer wirken statt weiterhin besetzt - genau wie bei einem
|
// oben), soll sein Schreibtisch leer wirken statt weiterhin besetzt - genau wie bei einem
|
||||||
// Tuer-/PO-Besuch verlaesst die Person sichtbar ihren Platz.
|
// Tuer-/PO-Besuch verlaesst die Person sichtbar ihren Platz.
|
||||||
const visitingAgentIds = new Set(agentBoardVisitors.keys());
|
const visitingAgentIds = new Set(agentBoardVisitors.keys());
|
||||||
const deskAgents = normalizedDeskAgents(agents).map((agent) =>
|
const deskAgents = normalizedDeskAgents(agents)
|
||||||
agent && visitingAgentIds.has(agent.id) ? null : agent
|
.map((agent) => (agent && visitingAgentIds.has(agent.id) ? null : agent))
|
||||||
|
// Die Warteschlange gehoert keinem Agenten mehr, ihr Zaehler steht deshalb neben der
|
||||||
|
// Agentenliste (GET /api/v1/agents). Gezeigt wird er am Schreibtisch des Voice-Bots -
|
||||||
|
// dort, wo auch die Figur sitzt, ueber die man aufnimmt.
|
||||||
|
.map((agent) =>
|
||||||
|
agent && agent.role === "voice" ? { ...agent, pending_recordings: pendingRecordings } : agent
|
||||||
);
|
);
|
||||||
const deskLayout = buildDeskLayout(deskAgents);
|
const deskLayout = buildDeskLayout(deskAgents);
|
||||||
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
|
const poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ export const ROLES = {
|
||||||
product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" },
|
product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" },
|
||||||
qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" },
|
qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" },
|
||||||
ops: { label: "OPs", accentColor: "#8b5cf6", deco: "gear" },
|
ops: { label: "OPs", accentColor: "#8b5cf6", deco: "gear" },
|
||||||
|
// Der Bot, der die Sprachaufnahmen abholt, transkribiert und daraus Tickets macht. Nur
|
||||||
|
// seine Figur fuehrt zur Aufnahmeseite, an seinem Schreibtisch steht der Zaehler offener
|
||||||
|
// Aufnahmen - das Mikrofon als Deko macht ihn in der Szene auffindbar.
|
||||||
|
voice: { label: "Voice-Bot", accentColor: "#14b8a6", deco: "mic" },
|
||||||
user: { label: "User", accentColor: "#f59e0b", deco: "none" },
|
user: { label: "User", accentColor: "#f59e0b", deco: "none" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
// Aufnahme-Webseite fuer Sprachnotizen an einen Agenten (Issue #85, Folge-Ticket aus #84).
|
// Aufnahme-Webseite fuer Sprachnotizen. Die Aufnahme geht in eine Warteschlange fuer alle,
|
||||||
|
// nicht an einen bestimmten Agenten: abgeholt wird sie vom Voice-Bot, und in welchem Repo
|
||||||
|
// daraus ein Ticket entsteht, entscheidet er anhand des Transkripts.
|
||||||
// Laeuft im Handy-Browser eines Menschen, getrennt von der Buero-Visualisierung
|
// Laeuft im Handy-Browser eines Menschen, getrennt von der Buero-Visualisierung
|
||||||
// (office.html/office-render.js) - reines Vanilla-JS/DOM ohne Bundler, wie der Rest des
|
// (office.html/office-render.js) - reines Vanilla-JS/DOM ohne Bundler, wie der Rest des
|
||||||
// Frontends. navigator.mediaDevices.getUserMedia()/MediaRecorder verlangen HTTPS (Ausnahme:
|
// Frontends. navigator.mediaDevices.getUserMedia()/MediaRecorder verlangen HTTPS (Ausnahme:
|
||||||
// localhost) - ohne das bricht init() kontrolliert mit einer Fehlermeldung ab, statt dass der
|
// localhost) - ohne das bricht init() kontrolliert mit einer Fehlermeldung ab, statt dass der
|
||||||
// Start-Knopf stumm nichts tut.
|
// Start-Knopf stumm nichts tut.
|
||||||
|
|
||||||
import { fetchAgent, uploadRecording } from "./office-api.js";
|
import { fetchVoiceAgent, uploadRecording } from "./office-api.js";
|
||||||
|
|
||||||
const agentNameEl = document.getElementById("agent-name");
|
const targetEl = document.getElementById("record-target");
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
const startButton = document.getElementById("start-button");
|
const startButton = document.getElementById("start-button");
|
||||||
const stopButton = document.getElementById("stop-button");
|
const stopButton = document.getElementById("stop-button");
|
||||||
|
|
@ -15,8 +17,6 @@ const uploadButton = document.getElementById("upload-button");
|
||||||
const discardButton = document.getElementById("discard-button");
|
const discardButton = document.getElementById("discard-button");
|
||||||
const audioPreview = document.getElementById("audio-preview");
|
const audioPreview = document.getElementById("audio-preview");
|
||||||
|
|
||||||
const agentId = new URLSearchParams(window.location.search).get("agent");
|
|
||||||
|
|
||||||
// MediaRecorder erzeugt je nach Browser unterschiedliche Container/Codecs - der erste vom
|
// MediaRecorder erzeugt je nach Browser unterschiedliche Container/Codecs - der erste vom
|
||||||
// Browser unterstuetzte Typ gewinnt. webm/Opus zuerst (Chrome/Firefox/Desktop), mp4/AAC als
|
// Browser unterstuetzte Typ gewinnt. webm/Opus zuerst (Chrome/Firefox/Desktop), mp4/AAC als
|
||||||
// Fallback fuer Safari/iOS (kennt kein webm). Muss zur Whitelist in
|
// Fallback fuer Safari/iOS (kennt kein webm). Muss zur Whitelist in
|
||||||
|
|
@ -41,24 +41,23 @@ function pickMimeType() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!agentId) {
|
|
||||||
setStatus("Kein Agent angegeben - der Link muss ?agent=<id> enthalten.", true);
|
|
||||||
startButton.disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await fetchAgent(agentId);
|
const voiceAgent = await fetchVoiceAgent();
|
||||||
if (!agent) {
|
if (voiceAgent) {
|
||||||
setStatus(`Unbekannter Agent (Id ${agentId}).`, true);
|
const name = voiceAgent.display_name ?? voiceAgent.name ?? "Voice-Bot";
|
||||||
startButton.disabled = true;
|
// Ein abwesender Bot ist kein Grund, die Aufnahme zu verhindern - sie wartet dann
|
||||||
return;
|
// eben, bis er wieder da ist. Gesagt wird es trotzdem, sonst wundert man sich, warum
|
||||||
|
// kein Ticket entsteht.
|
||||||
|
targetEl.textContent = voiceAgent.is_present === false
|
||||||
|
? `${name} (zurzeit nicht erreichbar - die Aufnahme wartet)`
|
||||||
|
: name;
|
||||||
|
} else {
|
||||||
|
targetEl.textContent = "kein Voice-Bot registriert - die Aufnahme bleibt liegen";
|
||||||
}
|
}
|
||||||
agentNameEl.textContent = agent.display_name ?? agent.name ?? `Agent ${agentId}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
// API evtl. gerade nicht erreichbar - der Upload braucht sie erst am Ende, die Aufnahme
|
// API evtl. gerade nicht erreichbar - der Upload braucht sie erst am Ende, die Aufnahme
|
||||||
// selbst funktioniert auch ohne Namen, deshalb hier nicht hart blockieren.
|
// selbst funktioniert auch ohne diese Anzeige, deshalb hier nicht hart blockieren.
|
||||||
agentNameEl.textContent = `Agent ${agentId}`;
|
targetEl.textContent = "Voice-Bot";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
|
if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
|
||||||
|
|
@ -133,7 +132,7 @@ async function upload() {
|
||||||
|
|
||||||
const extension = (recordedBlob.type.split("/")[1] || "webm").split(";")[0];
|
const extension = (recordedBlob.type.split("/")[1] || "webm").split(";")[0];
|
||||||
try {
|
try {
|
||||||
await uploadRecording(agentId, recordedBlob, `aufnahme.${extension}`);
|
await uploadRecording(recordedBlob, `aufnahme.${extension}`);
|
||||||
setStatus("Hochgeladen, danke!");
|
setStatus("Hochgeladen, danke!");
|
||||||
discardRecording();
|
discardRecording();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
<body>
|
<body>
|
||||||
<header class="record-header">
|
<header class="record-header">
|
||||||
<h1>Sprachaufnahme</h1>
|
<h1>Sprachaufnahme</h1>
|
||||||
<p>Für: <strong id="agent-name">…</strong></p>
|
<p>Wird abgeholt von: <strong id="record-target">…</strong></p>
|
||||||
|
<p class="record-hint">Nenne das Projekt am Anfang der Aufnahme – daraus entsteht ein Ticket im passenden Repo.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="record-main">
|
<main class="record-main">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue