Merge pull request 'Sprachaufnahmen: eine Warteschlange für alle statt Zuweisung an Agenten' (#90) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/90
This commit is contained in:
fds2610 2026-09-21 13:42:57 +02:00
commit 806fcdfb43
19 changed files with 467 additions and 302 deletions

68
API.md
View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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,
) {
}
/** @param array<string, string> $params */
public function upload(Request $request, array $params): Response
public function __construct(private readonly VoiceRecordingRepository $recordings)
{
$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;
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<string, string> $params
* @param array<string, mixed> $authenticatedAgent
*/
public function index(Request $request, array $params, array $authenticatedAgent): Response
/** @param array<string, mixed> $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') {

View 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;

View 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;

View file

@ -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

View file

@ -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<string, mixed> 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<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)) {
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<int, int> 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). */

View file

@ -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.

View file

@ -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']);
}
}

View file

@ -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);

View file

@ -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());
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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<object>, 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,
});

View file

@ -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,8 +2401,13 @@ 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;

View file

@ -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" },
};

View file

@ -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=<id> 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) {

View file

@ -10,7 +10,8 @@
<body>
<header class="record-header">
<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>
<main class="record-main">