# BAP Voice Contract

Technischer Vertrag fuer die spaetere BAP+ App. Die Web-Testseite ist nur ein Testbett und soll denselben JSON-Vertrag verwenden.

## Ziel

Die Audio-Schicht bleibt app-spezifisch. Die App liefert Text und Kontext an die Voice-Endpoints und erhaelt daraus entweder:

- einen aufgeloesten Suchauftrag
- oder eine gezielte Rueckfrage

Fuer den Uebergang mit `courtVacancies` ist `searchWindows[]` das bevorzugte Ergebnisformat.
Zusaetzlich gibt es bereits den fachlichen Intent `partner_search` fuer Mitspielersuche, auch wenn dafuer noch keine API existiert.

## Schritt 1: Voice Intent Resolve

Endpoint: `tools/kibap/voice_nlu.php`

Request:

```json
{
  "text": "Ich möchte am Wochenende nachmittags spielen. Sind noch Plätze frei?",
  "clubId": null,
  "preferredClubId": null,
  "mostRecentlyBookedCourtGroupId": null,
  "defaultAccount": {
    "id": 87549,
    "mostRecentlyBookedCourtGroupId": 267,
    "club": {
      "id": 8570,
      "name": "Maxi-Muster-Testverein Hamburg"
    }
  },
  "timezone": "Europe/Berlin",
  "nowIso": "2026-03-21T11:48:39+01:00",
  "courtGroups": [
    { "id": 245, "name": "Tennishalle", "minBookingDuration": 60 },
    { "id": 312, "name": "Schwimmbad", "minBookingDuration": 60 }
  ]
}
```

Regeln:

- `clubId` darf `null` sein. Dann soll spaeter der Lieblingsverein genutzt werden.
- `courtGroupId` wird nicht im Request uebergeben, sondern aus `text` plus Kontext abgeleitet.
- `defaultAccount` aus Login/`setAccount` ist die bevorzugte Kontextquelle.
- `defaultAccount.club.id` und `defaultAccount.club.name` repraesentieren den bereits gesetzten Lieblingsverein.
- `defaultAccount.mostRecentlyBookedCourtGroupId` repraesentiert die zuletzt gebuchte Anlage fuer Backend-Defaults.
- `preferredClubId` und `mostRecentlyBookedCourtGroupId` bleiben als optionale Override-Felder moeglich.

### Response: resolved

```json
{
  "status": "resolved",
  "intent": "availability_search",
  "clubId": null,
  "courtGroupId": 245,
  "courtGroupName": "Tennishalle",
  "searchWindows": [
    {
      "start": "2026-03-27T14:00:00+01:00",
      "numberOfHours": 4,
      "courtGroupId": 245
    },
    {
      "start": "2026-03-28T14:00:00+01:00",
      "numberOfHours": 4,
      "courtGroupId": 245
    },
    {
      "start": "2026-03-29T14:00:00+01:00",
      "numberOfHours": 4,
      "courtGroupId": 245
    }
  ],
  "start": "2026-03-27T14:00:00+01:00",
  "end": "2026-03-29T18:00:00+01:00",
  "timeWindow": { "start": "14:00", "end": "18:00" },
  "durationMinutes": 60,
  "defaults": {
    "clubId": "favoriteClub",
    "courtGroupId": null
  },
  "defaultLabels": {
    "clubName": "Maxi-Muster-Testverein Hamburg",
    "courtGroupName": null
  },
  "missingOrAmbiguous": [],
  "clarifyingQuestion": null,
  "assumptions": [],
  "defaultsApplied": [
    "timeWindow:afternoon",
    "dateRange:nextWeekend",
    "clubId:favoriteClub",
    "duration:minBookingDuration"
  ],
  "clarifyingQuestions": []
}
```

### Response: partner_search

```json
{
  "status": "resolved",
  "intent": "partner_search",
  "clubId": null,
  "courtGroupId": null,
  "searchWindows": [
    {
      "start": "2026-03-23T14:00:00+01:00",
      "numberOfHours": 4,
      "courtGroupId": null
    }
  ],
  "partnerPreferences": {
    "gender": "any"
  },
  "defaults": {
    "clubId": "favoriteClub",
    "courtGroupId": "lastBookedCourtGroup"
  },
  "defaultLabels": {
    "clubName": "Maxi-Muster-Testverein Hamburg",
    "courtGroupName": "Tennishalle"
  }
}
```

Regeln:

- `Mitspieler` wird als generisches Maskulinum behandelt und bedeutet `gender = "any"`.
- `Mitspielerin` bedeutet explizit `gender = "female"`.
- Zeitauflösung und Default-Logik sind identisch zur Platzsuche.

### Response: needs_clarification

```json
{
  "status": "needs_clarification",
  "intent": "availability_search",
  "clubId": null,
  "courtGroupId": null,
  "searchWindows": [],
  "start": null,
  "end": null,
  "timeWindow": { "start": "14:00", "end": "18:00" },
  "durationMinutes": null,
  "defaults": {
    "clubId": "favoriteClub",
    "courtGroupId": "lastBookedCourtGroup"
  },
  "defaultLabels": {
    "clubName": "Maxi-Muster-Testverein Hamburg",
    "courtGroupName": "Tennishalle"
  },
  "missingOrAmbiguous": ["dateRange"],
  "clarifyingQuestion": "Meinst du noch dieses Wochenende, also heute oder morgen?",
  "assumptions": [],
  "defaultsApplied": [
    "timeWindow:afternoon",
    "clubId:favoriteClub",
    "courtGroupId:lastBookedCourtGroup",
    "duration:resolveAfterCourtGroupDefault"
  ],
  "clarifyingQuestions": [
    "Meinst du noch dieses Wochenende, also heute oder morgen?"
  ]
}
```

## Schritt 2: Voice Answer

Endpoint: `tools/kibap/voice_answer.php`

Request:

```json
{
  "userText": "Ich möchte am Wochenende nachmittags spielen. Sind noch Plätze frei?",
  "nlu": {},
  "slots": null
}
```

Regeln:

- Wenn `nlu.status = "needs_clarification"`, soll nur die Rueckfrage formuliert werden.
- Wenn `nlu.status = "resolved"` und `slots = null`, soll eine Suchbestaetigung formuliert werden.
- Wenn `slots` spaeter gesetzt sind, soll daraus eine konkrete Antwort mit 1-2 Optionen entstehen.
- Bei `intent = "partner_search"` soll die Antwort als Mitspielersuche formuliert werden, nicht als Platzsuche.

Response:

```json
{
  "answerText": "Meinst du noch dieses Wochenende, also heute oder morgen?"
}
```

## App-Schnitt

Fuer die BAP+ App sollte die Voice-Kette getrennt bleiben:

1. Audio aufnehmen
2. Voice-to-text in der App oder ueber einen separaten Dienst
3. `voice_nlu.php` aufrufen
4. Bei `needs_clarification` Rueckfrage vorlesen und neue Nutzereingabe erfassen
5. Bei `resolved` fuer jedes Objekt in `searchWindows[]` einen `courtVacancies`-Call ausfuehren
6. `voice_answer.php` fuer die finale Formulierung nutzen

## Login-Kontext aus BAPI

Nach Login und automatischem `setAccount` ist `defaultAccount` die kanonische Quelle fuer die Voice-Defaults:

- Lieblingsverein: `defaultAccount.club.id` und `defaultAccount.club.name`
- zuletzt gebuchte Anlage: `defaultAccount.mostRecentlyBookedCourtGroupId`

Die BAP+ App sollte dieses Objekt direkt an den Voice-Flow durchreichen, statt dieselben Felder separat neu zusammenzubauen.

## Uebergang mit `courtVacancies`

Der aktuelle Zwischenstand ist auf `courtVacancies` ausgelegt:

- Pro Suchfenster genau ein `courtVacancies`-Call
- benoetigte Felder:
  - `start`
  - `numberOfHours`
  - optional `courtGroupId`

Beispiel fuer `Gibt es freie Plaetze am Montag nach 4 Uhr oder Donnerstag nach 6?`:

```json
{
  "status": "resolved",
  "intent": "availability_search",
  "searchWindows": [
    {
      "start": "2026-03-23T16:00:00+01:00",
      "numberOfHours": 4,
      "courtGroupId": null
    },
    {
      "start": "2026-03-26T18:00:00+01:00",
      "numberOfHours": 4,
      "courtGroupId": null
    }
  ]
}
```

Zeitregel fuer implizite Nachmittagszeiten:

- `1 Uhr` bis `6 Uhr` ohne Zusatz werden als `13:00` bis `18:00` verstanden
- `7 Uhr` und `8 Uhr` ohne Zusatz sind mehrdeutig und erzeugen eine Rueckfrage

## Warum diese Trennung

- Audio-Handling ist plattformabhaengig
- NLU, Defaults und Rueckfragelogik sollen zwischen Web-Test und BAP+ App identisch bleiben
- Der JSON-Vertrag ist damit die stabile Grenze zwischen App und Buchungslogik
