From be1d61350b6ec4f7e57e33058f834fc52015a307 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 21 Sep 2026 13:41:29 +0200 Subject: [PATCH] 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 --- API.md | 68 +++++---- api/src/Application.php | 24 ++-- api/src/Controller/AgentController.php | 19 ++- .../Controller/VoiceRecordingController.php | 54 +++---- .../007_voice_recordings_global.sql | 37 +++++ .../Db/migrations/008_agent_role_voice.sql | 33 +++++ api/src/Repository/AgentRepository.php | 2 +- .../Repository/VoiceRecordingRepository.php | 66 +++++---- api/tests/Unit/ApplicationTest.php | 84 +++++++++-- .../Unit/Controller/AgentControllerTest.php | 16 +-- .../VoiceRecordingControllerTest.php | 134 +++++++----------- .../VoiceRecordingRepositoryTest.php | 45 +++--- html/css/office.css | 13 ++ html/css/record.css | 8 ++ html/js/office-api.js | 42 +++--- html/js/office-render.js | 78 +++++++--- html/js/office-scene.js | 4 + html/js/record.js | 39 +++-- html/record.html | 3 +- 19 files changed, 467 insertions(+), 302 deletions(-) create mode 100644 api/src/Db/migrations/007_voice_recordings_global.sql create mode 100644 api/src/Db/migrations/008_agent_role_voice.sql diff --git a/API.md b/API.md index 478137a..268df6c 100644 --- a/API.md +++ b/API.md @@ -65,7 +65,7 @@ Fehlt oder passt ein Key nicht, antwortet die API mit `401 Unauthorized`. ## 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 (niedrigster freier Index). Die Rolle `user` bekommt nie einen Schreibtisch (kein fester 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 `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 Auth: `X-Admin-Key`. @@ -101,10 +108,11 @@ Auth: `X-Admin-Key`. ### `GET /api/v1/agents` — Alle Agenten -Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ] }`. Jeder Agent traegt -zusaetzlich `pending_recordings` (Issue #85): Anzahl offener Sprachaufnahmen in seiner -Warteschlange (`status: "new"`), Grundlage fuer den Zaehler auf seinem Schreibtisch in der -Buero-Visualisierung, siehe [Sprachaufnahmen](#sprachaufnahmen). +Response: `{ "agents": [ ‹Agent-Objekte wie oben, ohne api_key› ], "pending_recordings": 0 }`. +`pending_recordings` ist die Anzahl offener Sprachaufnahmen (`status: "new"`) in der +gemeinsamen Warteschlange - ein Wert fuers ganze Buero, kein Feld je Agent, seit die +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 `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 -Issue #85 (Folge-Ticket aus #84): Sprachnotizen fuer einen Agenten, aufgenommen ueber -`record.html` im Handy-Browser. Die eigentliche Transkription passiert agent-seitig -(whisper.cpp/faster-whisper, siehe [fds2610/Agent#17](https://codeberg.org/fds2610/Agent/issues/17)) - -diese API liefert nur die Rohaufnahme mit Status. +Sprachnotizen, aufgenommen ueber `record.html` im Handy-Browser. Die eigentliche +Transkription passiert bot-seitig (whisper.cpp, siehe `bin/voice-recording-agent.sh` in +[fds2610/Agent](https://codeberg.org/fds2610/Agent)) - diese API liefert nur die Rohaufnahme +mit Status. -In der Buero-Visualisierung verlinkt ein Klick auf die Personen-Figur jedes registrierten -Agenten auf `record.html?agent=‹id›`; ein kleiner Zaehler an seinem Schreibtisch zeigt, wie -viele Aufnahmen dort noch auf Abholung warten (`pending_recordings`, siehe -[Agenten](#agenten)). +**Eine Warteschlange fuer alle, ohne Empfaenger.** Bis einschliesslich Migration +`006_voice_recordings.sql` hing jede Aufnahme an einer Agenten-Id, und jede Agentenfigur in +der Visualisierung verlinkte auf ihre eigene Warteschlange. Das hat Warteschlangen erzeugt, +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 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`, `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 { "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 } } ``` -`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` des betroffenen Agenten (ein Agent darf nur seine eigene Warteschlange -abfragen, sonst `403`) - fuer das regelmaessige Abfragen aus [fds2610/Agent#17](https://codeberg.org/fds2610/Agent/issues/17). +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 +ab. Ein zweiter Bot mit eigenem Key waere sonst ohne Not ausgesperrt. 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 -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. ### `PATCH /api/v1/recordings/{id}` — Als verarbeitet markieren -Auth: `X-Api-Key` des Agenten, dem die Aufnahme gehoert (sonst `403`). Nach erfolgreicher -Abholung/Transkription markiert der Agent die Aufnahme so, damit sie nicht erneut in -`GET .../recordings?status=new` auftaucht. +Auth: `X-Api-Key` eines registrierten Agenten. Nach erfolgreicher Abholung/Transkription +markiert der Bot die Aufnahme so, damit sie nicht erneut in +`GET /api/v1/recordings?status=new` auftaucht. ```json // Request diff --git a/api/src/Application.php b/api/src/Application.php index 8c7b75d..cd137a0 100644 --- a/api/src/Application.php +++ b/api/src/Application.php @@ -72,7 +72,7 @@ final class Application $this->webhookAuth = new WebhookAuthenticator($config->codebergWebhookSecret()); $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, // unabhaengig vom periodisch/webhook-getriebenen sync-Container (bin/sync-codeberg.php) // - beide teilen sich denselben CodebergSyncService, nur der Aufloeser ist verschieden. @@ -160,21 +160,23 @@ final class Application 'agent' ); - // Sprachaufnahmen (Issue #85): Upload aus record.html ist bewusst unauthentifiziert - - // die Aufnahme-Webseite laeuft im Handy-Browser eines Menschen, nicht eines - // registrierten Agenten, es gibt dort also keinen Api-/Admin-Key einzugeben. Abholen/ - // Markieren dagegen hinter dem Agenten-Key wie die uebrigen Bot-Vorgaenge, mit - // Ownership-Check in VoiceRecordingController (ein Agent sieht nur seine eigene - // Warteschlange). + // Sprachaufnahmen: Upload aus record.html ist bewusst unauthentifiziert - die + // Aufnahme-Webseite laeuft im Handy-Browser eines Menschen, nicht eines registrierten + // Agenten, es gibt dort also keinen Api-/Admin-Key einzugeben. Abholen/Markieren + // dagegen hinter dem Agenten-Key wie die uebrigen Bot-Vorgaenge. + // + // 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( 'POST', - '/api/v1/agents/{id}/recordings', - fn (Request $r, array $p) => $recordingController->upload($r, $p) + '/api/v1/recordings', + fn (Request $r, array $p) => $recordingController->upload($r) ); $this->router->add( 'GET', - '/api/v1/agents/{id}/recordings', - fn (Request $r, array $p, array $agent) => $recordingController->index($r, $p, $agent), + '/api/v1/recordings', + fn (Request $r, array $p, array $agent) => $recordingController->index($r, $agent), 'agent' ); $this->router->add( diff --git a/api/src/Controller/AgentController.php b/api/src/Controller/AgentController.php index 6b325a5..0f3022c 100644 --- a/api/src/Controller/AgentController.php +++ b/api/src/Controller/AgentController.php @@ -19,20 +19,17 @@ final class AgentController } /** - * Issue #85: jeder Agent traegt zusaetzlich pending_recordings - die Anzahl offener - * Sprachaufnahmen in seiner Warteschlange, Grundlage fuer den Zaehler auf seinem - * Schreibtisch in der Buero-Visualisierung. + * Neben den Agenten steht pending_recordings: die Anzahl offener Sprachaufnahmen in der + * Warteschlange. Ein Wert fuer das ganze Buero, kein Feld je Agent - die Warteschlange + * 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 { - $pendingCounts = $this->recordings->pendingCountsByAgent(); - - $agents = array_map( - fn (array $agent) => $agent + ['pending_recordings' => $pendingCounts[(int) $agent['id']] ?? 0], - $this->agents->all() - ); - - return Response::json(['agents' => $agents]); + return Response::json([ + 'agents' => $this->agents->all(), + 'pending_recordings' => $this->recordings->pendingCount(), + ]); } public function register(Request $request): Response diff --git a/api/src/Controller/VoiceRecordingController.php b/api/src/Controller/VoiceRecordingController.php index ca0a6da..3bce4c8 100644 --- a/api/src/Controller/VoiceRecordingController.php +++ b/api/src/Controller/VoiceRecordingController.php @@ -7,36 +7,31 @@ 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: + * 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). + * unauthentifiziert wie die uebrigen von der UI ausgeloesten Schreibvorgaenge auf + * oeffentlich verlinkten Seiten. + * - Abholen/Markieren durch den Voice-Bot - hinter dem bestehenden Agenten-API-Key. + * + * 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 { - public function __construct( - private readonly VoiceRecordingRepository $recordings, - private readonly AgentRepository $agents, - ) { + public function __construct(private readonly VoiceRecordingRepository $recordings) + { } - /** @param array $params */ - public function upload(Request $request, array $params): Response + public function upload(Request $request): 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); @@ -56,7 +51,7 @@ final class VoiceRecordingController } try { - $recording = $this->recordings->store($agentId, $contents, (string) ($file['type'] ?? '')); + $recording = $this->recordings->store($contents, (string) ($file['type'] ?? '')); } catch (InvalidArgumentException $e) { return Response::error($e->getMessage(), 422); } @@ -64,20 +59,13 @@ final class VoiceRecordingController return Response::json(['recording' => $recording], 201); } - /** - * @param array $params - * @param array $authenticatedAgent - */ - public function index(Request $request, array $params, array $authenticatedAgent): Response + /** @param array $authenticatedAgent */ + public function index(Request $request, 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); + $recordings = $this->recordings->listAll($status); } catch (InvalidArgumentException $e) { return Response::error($e->getMessage(), 400); } @@ -95,9 +83,6 @@ final class VoiceRecordingController 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) { @@ -119,9 +104,6 @@ final class VoiceRecordingController 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') { diff --git a/api/src/Db/migrations/007_voice_recordings_global.sql b/api/src/Db/migrations/007_voice_recordings_global.sql new file mode 100644 index 0000000..13b238f --- /dev/null +++ b/api/src/Db/migrations/007_voice_recordings_global.sql @@ -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; diff --git a/api/src/Db/migrations/008_agent_role_voice.sql b/api/src/Db/migrations/008_agent_role_voice.sql new file mode 100644 index 0000000..ba40fe7 --- /dev/null +++ b/api/src/Db/migrations/008_agent_role_voice.sql @@ -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; diff --git a/api/src/Repository/AgentRepository.php b/api/src/Repository/AgentRepository.php index e9224b2..8035c52 100644 --- a/api/src/Repository/AgentRepository.php +++ b/api/src/Repository/AgentRepository.php @@ -12,7 +12,7 @@ use Monitor\Api\Http\NotFoundException; final class AgentRepository { /** @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 diff --git a/api/src/Repository/VoiceRecordingRepository.php b/api/src/Repository/VoiceRecordingRepository.php index 1053ebc..adb7833 100644 --- a/api/src/Repository/VoiceRecordingRepository.php +++ b/api/src/Repository/VoiceRecordingRepository.php @@ -8,11 +8,17 @@ use InvalidArgumentException; use Monitor\Api\Db\Database; /** - * Warteschlange fuer Sprachaufnahmen (Issue #85): record.html laedt eine Aufnahme fuer einen - * Agenten hoch, der Agent fragt regelmaessig ab, ob Aufnahmen vorliegen, holt sie ab und - * markiert sie danach als erledigt (siehe fds2610/Agent#17 fuer die Agenten-Seite). Die - * eigentlichen Audiodateien liegen nicht in SQLite, sondern unter $storageDir - hier steht - * nur ihr Dateiname (storage_name) plus Metadaten. + * Eine globale Warteschlange fuer Sprachaufnahmen: record.html laedt eine Aufnahme hoch, der + * Voice-Bot (Rolle "voice", siehe fds2610/Agent, bin/voice-recording-agent.sh) fragt + * regelmaessig ab, ob Aufnahmen vorliegen, holt sie ab und markiert sie danach als erledigt. + * + * 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 { @@ -44,7 +50,7 @@ final class VoiceRecordingRepository * @return array das neu angelegte Aufnahme-Objekt, ohne storage_name (siehe * toPublic()). */ - public function store(int $agentId, string $contents, string $rawMimeType): array + public function store(string $contents, string $rawMimeType): array { $sizeBytes = strlen($contents); if ($sizeBytes === 0) { @@ -73,11 +79,10 @@ final class VoiceRecordingRepository $now = $this->db->now(); $stmt = $this->db->pdo()->prepare( - 'INSERT INTO voice_recordings (agent_id, storage_name, mime_type, size_bytes, status, created_at) - VALUES (: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 (:storage_name, :mime_type, :size_bytes, :status, :created_at)' ); $stmt->execute([ - 'agent_id' => $agentId, 'storage_name' => $storageName, 'mime_type' => $mimeType, 'size_bytes' => $sizeBytes, @@ -88,18 +93,23 @@ final class VoiceRecordingRepository return $this->toPublic($this->findRawOrFail((int) $this->db->pdo()->lastInsertId())); } - /** @return array> */ - 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> + */ + public function listAll(?string $status = null): array { if ($status !== null && !in_array($status, ['new', 'done'], true)) { throw new InvalidArgumentException('Unbekannter Status: ' . $status); } - $sql = 'SELECT id, agent_id, mime_type, size_bytes, status, created_at, processed_at - FROM voice_recordings WHERE agent_id = :agent_id'; - $params = ['agent_id' => $agentId]; + $sql = 'SELECT id, mime_type, size_bytes, status, created_at, processed_at + FROM voice_recordings'; + $params = []; if ($status !== null) { - $sql .= ' AND status = :status'; + $sql .= ' WHERE status = :status'; $params['status'] = $status; } $sql .= ' ORDER BY created_at ASC'; @@ -110,11 +120,11 @@ final class VoiceRecordingRepository 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 { $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' ); $stmt->execute(['id' => $id]); @@ -152,23 +162,17 @@ final class VoiceRecordingRepository } /** - * Anzahl offener (status='new') Aufnahmen je Agent - Grundlage fuer den Zaehler auf dem - * Schreibtisch (GET /api/v1/agents, siehe AgentController::index()). - * - * @return array Agent-Id => Anzahl. + * Anzahl offener (status='new') Aufnahmen in der Warteschlange - Grundlage fuer den + * Zaehler am Schreibtisch des Voice-Bots (GET /api/v1/agents, siehe + * AgentController::index()). */ - public function pendingCountsByAgent(): array + public function pendingCount(): int { - $stmt = $this->db->pdo()->query( - "SELECT agent_id, COUNT(*) AS count FROM voice_recordings WHERE status = 'new' GROUP BY agent_id" - ); + $row = $this->db->pdo()->query( + "SELECT COUNT(*) AS count FROM voice_recordings WHERE status = 'new'" + )->fetch(); - $counts = []; - foreach ($stmt->fetchAll() as $row) { - $counts[(int) $row['agent_id']] = (int) $row['count']; - } - - return $counts; + return (int) $row['count']; } /** Fingerabdruck fuer den SSE-Endpoint (Realtime\ChangeFeed, F13). */ diff --git a/api/tests/Unit/ApplicationTest.php b/api/tests/Unit/ApplicationTest.php index b39a614..724cfd4 100644 --- a/api/tests/Unit/ApplicationTest.php +++ b/api/tests/Unit/ApplicationTest.php @@ -308,9 +308,9 @@ final class ApplicationTest extends TestCase } /** - * Issue #85: voller Ablauf einer Sprachaufnahme - Upload aus record.html (unauthentifiziert, - * Agenten-Id per URL), Abholen/Herunterladen/Markieren durch den Agenten selbst (hinter - * seinem eigenen X-Api-Key). + * Voller Ablauf einer Sprachaufnahme - Upload aus record.html (unauthentifiziert, ohne + * Agenten-Id), Abholen/Herunterladen/Markieren durch den Voice-Bot (hinter seinem + * X-Api-Key). */ public function testVoiceRecordingFullFlow(): void { @@ -324,18 +324,17 @@ final class ApplicationTest extends TestCase $registerResponse = $app->handle(Request::create( 'POST', '/api/v1/agents', - body: ['display_name' => 'Alice', 'role' => 'entwickler'], + body: ['display_name' => 'Vox', 'role' => 'voice'], headers: ['X-Admin-Key' => 'admin-secret'] )); $apiKey = $registerResponse->body()['api_key']; - $agentId = $registerResponse->body()['agent']['id']; $tmpUploadFile = sys_get_temp_dir() . '/monitor-app-upload-' . uniqid(); file_put_contents($tmpUploadFile, 'echte audiodaten'); $uploadResponse = $app->handle(Request::create( 'POST', - '/api/v1/agents/' . $agentId . '/recordings', + '/api/v1/recordings', files: ['audio' => [ 'name' => 'aufnahme.webm', 'type' => 'audio/webm', @@ -348,11 +347,11 @@ final class ApplicationTest extends TestCase $recordingId = $uploadResponse->body()['recording']['id']; $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( 'GET', - '/api/v1/agents/' . $agentId . '/recordings', + '/api/v1/recordings', query: ['status' => 'new'], headers: ['X-Api-Key' => $apiKey] )); @@ -377,7 +376,7 @@ final class ApplicationTest extends TestCase self::assertSame('done', $markResponse->body()['recording']['status']); $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); foreach (glob($storageDir . '/*') ?: [] as $file) { @@ -386,20 +385,79 @@ final class ApplicationTest extends TestCase 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 { - $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); } + /** + * 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 * Webhook-Zustellungen unabhaengig vom Event-Typ. diff --git a/api/tests/Unit/Controller/AgentControllerTest.php b/api/tests/Unit/Controller/AgentControllerTest.php index af6617c..5387c9d 100644 --- a/api/tests/Unit/Controller/AgentControllerTest.php +++ b/api/tests/Unit/Controller/AgentControllerTest.php @@ -132,20 +132,20 @@ final class AgentControllerTest extends TestCase } /** - * Issue #85: der Zaehler auf dem Schreibtisch haengt an diesem Feld - GET /api/v1/agents - * reichert jeden Agenten um die Anzahl offener Sprachaufnahmen an. + * Der Zaehler auf dem Schreibtisch des Voice-Bots haengt an diesem Feld - es steht neben + * der Agentenliste, nicht an jedem Agenten: die Warteschlange ist global. */ public function testIndexIncludesPendingRecordingsCount(): void { - $registration = $this->repository->register('Alice', 'entwickler'); - $agentId = (int) $registration['agent']['id']; - $done = $this->recordings->store($agentId, 'a', 'audio/webm'); - $this->recordings->store($agentId, 'b', 'audio/webm'); + $this->repository->register('Vox', 'voice'); + $done = $this->recordings->store('a', 'audio/webm'); + $this->recordings->store('b', 'audio/webm'); $this->recordings->markDone((int) $done['id']); $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 @@ -154,6 +154,6 @@ final class AgentControllerTest extends TestCase $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']); } } diff --git a/api/tests/Unit/Controller/VoiceRecordingControllerTest.php b/api/tests/Unit/Controller/VoiceRecordingControllerTest.php index 89a5513..a2f437b 100644 --- a/api/tests/Unit/Controller/VoiceRecordingControllerTest.php +++ b/api/tests/Unit/Controller/VoiceRecordingControllerTest.php @@ -25,7 +25,7 @@ final class VoiceRecordingControllerTest extends TestCase $this->agents = new AgentRepository($db); $this->storageDir = sys_get_temp_dir() . '/monitor-recordings-controller-' . uniqid(); $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(); 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( - Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]), - ['id' => '999'] - ); - - self::assertSame(404, $response->status); + return $this->agents->register('Vox', 'voice')['agent']; } public function testUploadRejectsMissingFile(): void { - $agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id']; - - $response = $this->controller->upload(Request::create('POST', '/x'), ['id' => (string) $agentId]); + $response = $this->controller->upload(Request::create('POST', '/x')); self::assertSame(422, $response->status); } public function testUploadRejectsTooLargeUploadError(): void { - $agentId = (int) $this->agents->register('Alice', 'entwickler')['agent']['id']; - $response = $this->controller->upload( - Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField(UPLOAD_ERR_INI_SIZE)]), - ['id' => (string) $agentId] + Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField(UPLOAD_ERR_INI_SIZE)]) ); 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( - Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]), - ['id' => (string) $agentId] + Request::create('POST', '/x', files: ['audio' => $this->uploadedAudioField()]) ); 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']); } - public function testIndexRejectsMismatchedAgentId(): void + public function testIndexListsTheSharedQueue(): void { - $registration = $this->agents->register('Alice', 'entwickler'); - $other = $this->agents->register('Bob', 'qa'); + $this->recordings->store('a', 'audio/webm'); - $response = $this->controller->index( - Request::create('GET', '/x'), - ['id' => (string) $other['agent']['id']], - $registration['agent'] - ); + $response = $this->controller->index(Request::create('GET', '/x'), $this->voiceBot()); - 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'); - $agentId = (int) $registration['agent']['id']; - $this->recordings->store($agentId, 'a', 'audio/webm'); + $done = $this->recordings->store('a', 'audio/webm'); + $this->recordings->store('b', 'audio/webm'); + $this->recordings->markDone((int) $done['id']); $response = $this->controller->index( - Request::create('GET', '/x'), - ['id' => (string) $agentId], - $registration['agent'] + Request::create('GET', '/x', query: ['status' => 'new']), + $this->voiceBot() ); self::assertSame(200, $response->status); self::assertCount(1, $response->body()['recordings']); } - public function testDownloadRejectsForeignAgent(): void + public function testIndexRejectsUnknownStatus(): 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->download( - Request::create('GET', '/x'), - ['id' => (string) $recording['id']], - $registration['agent'] + $response = $this->controller->index( + Request::create('GET', '/x', query: ['status' => 'archived']), + $this->voiceBot() ); - 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'); - $agentId = (int) $registration['agent']['id']; - $recording = $this->recordings->store($agentId, 'echte audiodaten', 'audio/webm'); + $recording = $this->recordings->store('echte audiodaten', 'audio/webm'); + $otherAgent = $this->agents->register('OPs', 'ops')['agent']; $response = $this->controller->download( Request::create('GET', '/x'), ['id' => (string) $recording['id']], - $registration['agent'] + $otherAgent ); self::assertSame(200, $response->status); @@ -164,57 +150,47 @@ final class VoiceRecordingControllerTest extends TestCase public function testDownloadReturns404ForUnknownRecording(): void { - $registration = $this->agents->register('Alice', 'entwickler'); - $response = $this->controller->download( Request::create('GET', '/x'), ['id' => '999'], - $registration['agent'] + $this->voiceBot() ); 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 { - $registration = $this->agents->register('Alice', 'entwickler'); - $agentId = (int) $registration['agent']['id']; - $recording = $this->recordings->store($agentId, 'a', 'audio/webm'); + $recording = $this->recordings->store('a', 'audio/webm'); $response = $this->controller->markProcessed( Request::create('PATCH', '/x', body: ['status' => 'archived']), ['id' => (string) $recording['id']], - $registration['agent'] + $this->voiceBot() ); self::assertSame(422, $response->status); } - public function testMarkProcessedMarksOwnRecordingDone(): void + public function testMarkProcessedReturns404ForUnknownRecording(): void { - $registration = $this->agents->register('Alice', 'entwickler'); - $agentId = (int) $registration['agent']['id']; - $recording = $this->recordings->store($agentId, 'a', 'audio/webm'); + $response = $this->controller->markProcessed( + Request::create('PATCH', '/x', body: ['status' => 'done']), + ['id' => '999'], + $this->voiceBot() + ); + + self::assertSame(404, $response->status); + } + + public function testMarkProcessedMarksRecordingDone(): void + { + $recording = $this->recordings->store('a', 'audio/webm'); $response = $this->controller->markProcessed( Request::create('PATCH', '/x', body: ['status' => 'done']), ['id' => (string) $recording['id']], - $registration['agent'] + $this->voiceBot() ); self::assertSame(200, $response->status); diff --git a/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php b/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php index ff6314e..c71be3b 100644 --- a/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php +++ b/api/tests/Unit/Repository/VoiceRecordingRepositoryTest.php @@ -6,7 +6,6 @@ namespace Monitor\Api\Tests\Repository; use InvalidArgumentException; use Monitor\Api\Db\Database; -use Monitor\Api\Repository\AgentRepository; use Monitor\Api\Repository\VoiceRecordingRepository; use PHPUnit\Framework\TestCase; @@ -14,14 +13,10 @@ final class VoiceRecordingRepositoryTest extends TestCase { private VoiceRecordingRepository $recordings; private string $storageDir; - private int $agentId; protected function setUp(): void { $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->recordings = new VoiceRecordingRepository($db, $this->storageDir, 1024); } @@ -40,62 +35,62 @@ final class VoiceRecordingRepositoryTest extends TestCase { $this->expectException(InvalidArgumentException::class); - $this->recordings->store($this->agentId, '', 'audio/webm'); + $this->recordings->store('', 'audio/webm'); } public function testStoreRejectsFilesAboveTheLimit(): void { $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 { $this->expectException(InvalidArgumentException::class); - $this->recordings->store($this->agentId, 'daten', 'application/octet-stream'); + $this->recordings->store('daten', 'application/octet-stream'); } 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']); } 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::assertSame($this->agentId, $recording['agent_id']); + self::assertArrayNotHasKey('agent_id', $recording); self::assertSame('new', $recording['status']); self::assertSame(strlen('echte audiodaten'), $recording['size_bytes']); 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'); - $this->recordings->store($this->agentId, 'b', 'audio/ogg'); + $recording = $this->recordings->store('a', 'audio/ogg'); + $this->recordings->store('b', 'audio/ogg'); $this->recordings->markDone((int) $recording['id']); - self::assertCount(1, $this->recordings->listForAgent($this->agentId, 'new')); - self::assertCount(1, $this->recordings->listForAgent($this->agentId, 'done')); - self::assertCount(2, $this->recordings->listForAgent($this->agentId)); + self::assertCount(1, $this->recordings->listAll('new')); + self::assertCount(1, $this->recordings->listAll('done')); + self::assertCount(2, $this->recordings->listAll()); } - public function testListForAgentRejectsUnknownStatus(): void + public function testListAllRejectsUnknownStatus(): void { $this->expectException(InvalidArgumentException::class); - $this->recordings->listForAgent($this->agentId, 'archived'); + $this->recordings->listAll('archived'); } 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']); @@ -103,13 +98,13 @@ final class VoiceRecordingRepositoryTest extends TestCase self::assertNotNull($updated['processed_at']); } - public function testPendingCountsByAgentCountsOnlyNewRecordings(): void + public function testPendingCountCountsOnlyNewRecordings(): void { - $first = $this->recordings->store($this->agentId, 'a', 'audio/mpeg'); - $this->recordings->store($this->agentId, 'b', 'audio/mpeg'); + $first = $this->recordings->store('a', 'audio/mpeg'); + $this->recordings->store('b', 'audio/mpeg'); $this->recordings->markDone((int) $first['id']); - self::assertSame([$this->agentId => 1], $this->recordings->pendingCountsByAgent()); + self::assertSame(1, $this->recordings->pendingCount()); } public function testReadAudioReturnsNullForUnknownId(): void @@ -120,7 +115,7 @@ final class VoiceRecordingRepositoryTest extends TestCase public function testFingerprintChangesWhenARecordingIsAdded(): void { $before = $this->recordings->fingerprint(); - $this->recordings->store($this->agentId, 'a', 'audio/webm'); + $this->recordings->store('a', 'audio/webm'); self::assertNotSame($before, $this->recordings->fingerprint()); } diff --git a/html/css/office.css b/html/css/office.css index 1774cb5..5709c78 100644 --- a/html/css/office.css +++ b/html/css/office.css @@ -409,6 +409,19 @@ header.office-header h1 { 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 { opacity: 0.5; } diff --git a/html/css/record.css b/html/css/record.css index 86d9a12..19b7c55 100644 --- a/html/css/record.css +++ b/html/css/record.css @@ -33,6 +33,14 @@ body { 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 { width: 100%; max-width: 420px; diff --git a/html/js/office-api.js b/html/js/office-api.js index 7ad6feb..7cbc26f 100644 --- a/html/js/office-api.js +++ b/html/js/office-api.js @@ -48,43 +48,51 @@ export async function fetchKpis() { 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, pendingRecordings: number}>} + */ export async function fetchAgents() { - const { agents } = await getJson("/agents"); - return agents; + const { agents, pending_recordings: pendingRecordings } = await getJson("/agents"); + return { agents, pendingRecordings: pendingRecordings ?? 0 }; } /** - * Issue #85: record.html zeigt den Namen des Agenten, fuer den gerade aufgenommen wird - holt - * dafuer einfach die volle (kleine) Agentenliste und filtert lokal, ein eigener - * GET-/agents/{id}-Endpunkt existiert nicht. + * Der Voice-Bot: der Agent, der die Sprachaufnahmen abholt. record.html nennt ihn beim Namen, + * damit erkennbar ist, wer die Aufnahme abarbeitet - und meldet, wenn gar keiner registriert + * ist, weil die Aufnahme dann unbearbeitet liegen bleibt. */ -export async function fetchAgent(agentId) { - const agents = await fetchAgents(); - return agents.find((agent) => String(agent.id) === String(agentId)) ?? null; +export async function fetchVoiceAgent() { + const { agents } = await fetchAgents(); + return agents.find((agent) => agent.role === "voice") ?? null; } /** - * Verlinkt von der Buero-Szene aus (Klick auf die Personen-Figur eines registrierten Agenten, - * Issue #85) auf die Aufnahme-Webseite. record.html liegt neben office.html im selben - * Verzeichnis, daher ein rein relativer Pfad statt ueber apiBase() (record.html spricht die - * API zwar auch an, ist selbst aber keine API-Route). + * Verlinkt von der Buero-Szene aus (Klick auf die Figur des Voice-Bots) auf die + * Aufnahme-Webseite. Ohne Agent-Parameter: die Aufnahme landet in einer Warteschlange fuer + * alle. record.html liegt neben office.html im selben Verzeichnis, daher ein rein relativer + * Pfad statt ueber apiBase() (record.html spricht die API zwar auch an, ist selbst aber keine + * API-Route). */ -export function recordingUrl(agentId) { - return `record.html?agent=${encodeURIComponent(agentId)}`; +export function recordingUrl() { + 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 * 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 * gesetzter Header wuerde die Boundary kaputt machen. */ -export async function uploadRecording(agentId, blob, filename) { +export async function uploadRecording(blob, filename) { const formData = new FormData(); formData.append("audio", blob, filename); - const response = await fetch(`${apiBase()}/agents/${encodeURIComponent(agentId)}/recordings`, { + const response = await fetch(`${apiBase()}/recordings`, { method: "POST", body: formData, }); diff --git a/html/js/office-render.js b/html/js/office-render.js index 1c04cd3..fec947e 100644 --- a/html/js/office-render.js +++ b/html/js/office-render.js @@ -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" })); - // Issue #85: Zaehler offener Sprachaufnahmen am Schreibtisch dieses Agenten - oben links, - // damit er nicht mit der rollenabhaengigen Deko (star/flower/check/gear) oben rechts - // kollidiert. Nur sichtbar, wenn tatsaechlich etwas in der Warteschlange liegt. + // Zaehler offener Sprachaufnahmen - oben links, damit er nicht mit der rollenabhaengigen + // Deko (star/flower/check/gear/mic) oben rechts kollidiert. Nur sichtbar, wenn tatsaechlich + // 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) { 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" }), ]) ); + } 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 @@ -954,17 +967,18 @@ function updateAgentsLayer(layer, placements) { const id = String(agent.id ?? `guest-${index}`); seen.add(id); - // Echte registrierte Agenten haben eine numerische Id von der API; Demo-Agenten und die - // guest-N-Fallback-Id kennen keine Aufnahme-Warteschlange, sind also nicht klickbar. - const isRealAgent = typeof agent.id !== "undefined" && id !== PO_AGENT_ID; + // Nur die Figur des Voice-Bots fuehrt zur Aufnahmeseite: er ist der einzige, der die + // Warteschlange abholt. Frueher war jede Agentenfigur klickbar - dabei entstanden + // Aufnahmen in Warteschlangen, die nie jemand geleert hat. + const isVoiceBot = agent.role === "voice" && id !== PO_AGENT_ID; let node = agentAvatarElements.get(id); if (!node) { node = createAgentAvatarElement(); if (id === PO_AGENT_ID) { makePoAvatarClickable(node); - } else if (isRealAgent) { - makeAgentAvatarClickable(node, agent.id); + } else if (isVoiceBot) { + makeVoiceBotAvatarClickable(node); } agentAvatarElements.set(id, node); layer.appendChild(node); @@ -1000,17 +1014,16 @@ function makePoAvatarClickable(node) { node.addEventListener("click", openRepoConfigModal); } -// Issue #85: ein Klick auf die Personen-Figur eines registrierten Agenten (nicht auf den -// synthetischen PO-Platz, siehe makePoAvatarClickable) oeffnet die Aufnahme-Webseite fuer -// diesen Agenten in einem neuen Tab - die laufende Buero-Szene bleibt dabei unangetastet -// stehen, genau wie ein Modal es waere, nur dass record.html eine eigene Seite ist (braucht -// Mikrofon-Zugriff und ist fuers Handy gedacht). -function makeAgentAvatarClickable(node, agentId) { +// Ein Klick auf die Figur des Voice-Bots oeffnet die Aufnahme-Webseite in einem neuen Tab - +// die laufende Buero-Szene bleibt dabei unangetastet stehen, genau wie ein Modal es waere, +// nur dass record.html eine eigene Seite ist (braucht Mikrofon-Zugriff und ist fuers Handy +// gedacht). Die Aufnahme geht in eine Warteschlange fuer alle, nicht an einen Agenten. +function makeVoiceBotAvatarClickable(node) { const title = document.createElementNS(SVG_NS, "title"); - title.textContent = "Sprachaufnahme fuer diesen Agenten aufnehmen"; + title.textContent = "Sprachnotiz aufnehmen"; node.appendChild(title); 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 error = ""; try { - const [hosts, agents, systems] = await Promise.all([fetchHostConfigs(), fetchAgents(), fetchSystems()]); - loaded = { hosts, agents, systems }; + const [hosts, agentsResult, systems] = await Promise.all([ + fetchHostConfigs(), + fetchAgents(), + fetchSystems(), + ]); + loaded = { hosts, agents: agentsResult.agents, systems }; } catch { error = "Hosts konnten nicht geladen werden."; } @@ -2260,7 +2277,7 @@ async function handleSystemDelete(system) { // --------------------------------------------------------------------------------------- async function loadLiveData() { - const [tickets, events, systems, kpis, agents, webhookStats] = await Promise.all([ + const [tickets, events, systems, kpis, agentsResult, webhookStats] = await Promise.all([ fetchTickets(), fetchEvents(), fetchSystems(), @@ -2268,7 +2285,16 @@ async function loadLiveData() { fetchAgents(), 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 }); @@ -2291,6 +2317,7 @@ export function renderOfficeScene(container, data = {}) { kpis = [], tickets = [], hooksCount = null, + pendingRecordings = 0, isLive = false, } = data; @@ -2374,9 +2401,14 @@ export function renderOfficeScene(container, data = {}) { // oben), soll sein Schreibtisch leer wirken statt weiterhin besetzt - genau wie bei einem // Tuer-/PO-Besuch verlaesst die Person sichtbar ihren Platz. const visitingAgentIds = new Set(agentBoardVisitors.keys()); - const deskAgents = normalizedDeskAgents(agents).map((agent) => - agent && visitingAgentIds.has(agent.id) ? null : agent - ); + const deskAgents = normalizedDeskAgents(agents) + .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 poDeskAgent = isLive ? { name: PO_DISPLAY_NAME, role: "product_owner", pose: "idle" } : null; // Auch ohne erreichbare API klickbar: das Modal sagt dann selbst, dass es die Projekte diff --git a/html/js/office-scene.js b/html/js/office-scene.js index 877b752..c3e0b88 100644 --- a/html/js/office-scene.js +++ b/html/js/office-scene.js @@ -19,6 +19,10 @@ export const ROLES = { product_owner: { label: "Product Owner:in", accentColor: "#ec4899", deco: "flower" }, qa: { label: "QA / Tester:in", accentColor: "#22c55e", deco: "check" }, 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" }, }; diff --git a/html/js/record.js b/html/js/record.js index e542b1e..96f891d 100644 --- a/html/js/record.js +++ b/html/js/record.js @@ -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 // (office.html/office-render.js) - reines Vanilla-JS/DOM ohne Bundler, wie der Rest des // Frontends. navigator.mediaDevices.getUserMedia()/MediaRecorder verlangen HTTPS (Ausnahme: // localhost) - ohne das bricht init() kontrolliert mit einer Fehlermeldung ab, statt dass der // 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 startButton = document.getElementById("start-button"); const stopButton = document.getElementById("stop-button"); @@ -15,8 +17,6 @@ const uploadButton = document.getElementById("upload-button"); const discardButton = document.getElementById("discard-button"); 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 // Browser unterstuetzte Typ gewinnt. webm/Opus zuerst (Chrome/Firefox/Desktop), mp4/AAC als // Fallback fuer Safari/iOS (kennt kein webm). Muss zur Whitelist in @@ -41,24 +41,23 @@ function pickMimeType() { } async function init() { - if (!agentId) { - setStatus("Kein Agent angegeben - der Link muss ?agent= enthalten.", true); - startButton.disabled = true; - return; - } - try { - const agent = await fetchAgent(agentId); - if (!agent) { - setStatus(`Unbekannter Agent (Id ${agentId}).`, true); - startButton.disabled = true; - return; + const voiceAgent = await fetchVoiceAgent(); + if (voiceAgent) { + const name = voiceAgent.display_name ?? voiceAgent.name ?? "Voice-Bot"; + // Ein abwesender Bot ist kein Grund, die Aufnahme zu verhindern - sie wartet dann + // 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 { // API evtl. gerade nicht erreichbar - der Upload braucht sie erst am Ende, die Aufnahme - // selbst funktioniert auch ohne Namen, deshalb hier nicht hart blockieren. - agentNameEl.textContent = `Agent ${agentId}`; + // selbst funktioniert auch ohne diese Anzeige, deshalb hier nicht hart blockieren. + targetEl.textContent = "Voice-Bot"; } if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { @@ -133,7 +132,7 @@ async function upload() { const extension = (recordedBlob.type.split("/")[1] || "webm").split(";")[0]; try { - await uploadRecording(agentId, recordedBlob, `aufnahme.${extension}`); + await uploadRecording(recordedBlob, `aufnahme.${extension}`); setStatus("Hochgeladen, danke!"); discardRecording(); } catch (error) { diff --git a/html/record.html b/html/record.html index eda4718..c8e4de3 100644 --- a/html/record.html +++ b/html/record.html @@ -10,7 +10,8 @@

Sprachaufnahme

-

Für: …

+

Wird abgeholt von: …

+

Nenne das Projekt am Anfang der Aufnahme – daraus entsteht ein Ticket im passenden Repo.