Files
ApiCore/docs/superpowers/plans/2026-10-16-t7-sprache.md
T
2026-10-16 09:12:04 +02:00

681 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# T7 „Freihand“ (Spracherfassung) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Der Imker spricht die Durchsicht statt zu tippen: Stufe 1 diktiert per Browser-Spracherkennung in die Freitextfelder, Stufe 2 nimmt eine Sprachnotiz auch offline auf, schreibt sie im Cluster mit faster-whisper ab und lässt Claude daraus Feldvorschläge mit wörtlichem Zitat machen. Kein Feld wird ohne Klick belegt, gespeichert wird nur über „Speichern“, Ton und Text sind danach gelöscht.
**Architecture:** Neues Backend-Modul `src/voice` mit vier Schichten, die Beute Teil 4 unverändert wiederverwendet: `core/` (Schlüsselbund-Zweck `voice`, Transkript-Hülle mit Segmenten, Text-Normalisierung und Zitatprüfung, Feld-Tabelle aus `CreateInspectionDto`, Fachwortliste), `claims/` (Claim-, Settle- und Stale-Funktionen mit eigener Wiederholregel je Phase), `extraction/` (Port `StructuredModel` mit Anthropic-Adapter, Prompt, Schema, reine Prüfung, Worker-Schleife `voice-extractions`) und `captures/` (API, Übernahme in `POST /inspections`, Aufräumen). Abschreiben läuft als CronJob `apicore-job-voice-transcribe` mit drei Containern (Node `fetch` → Python `whisper` ohne Netz und ohne Schlüssel → Node `ingest`). Frontend: `lib/voice` (Spracherkennungs-Kapsel, Einwilligung, Recorder, Warteschlange `pending-voice`, API, Vorschlags-Mapping) und `components/voice` (Diktat-Knopf, Aufnahme, generisches `VoiceSuggestion`).
**Tech Stack:** NestJS 11, Prisma 7.9 (`@prisma/adapter-pg`), PostgreSQL (Betrieb 18.6, Test-DB 17 auf Port 5433: keine 18-only-Funktionen), Jest 30, `@anthropic-ai/sdk` (neu, exakt gepinnt, ≥ 14 Tage alt am Installationstag), Multer über `@nestjs/platform-express` (vorhanden); Next.js 16, React 19.2, TanStack Query 5, `idb`, Vitest 4 + RTL + `fake-indexeddb` (vorhanden), axe über die vorhandene Test-Hilfe; Python 3.12-slim, faster-whisper, PyAV, pytest (nur im Image `apicore-whisper`).
**Spec:** `docs/superpowers/specs/2026-10-14-t7-sprache-design.md` (bindend). Anschluss-Vorgaben aus `docs/superpowers/specs/2026-10-16-beute-teil4-sprach-durchsicht-design.md` (Teil 4 baut auf T7 auf; T7 baut Teil 4 **nicht**, schneidet aber die Bausteine so, dass Teil 4 sie nur erweitert). Muster und Format: `docs/superpowers/plans/2026-10-14-t6-compliance.md`, `2026-10-15-beute-3d.md`.
---
## Spec-Widersprüche zum Code (geprüft am 2026-10-16, HEAD `b73da4d8`) und Auflösung
Jede Zeile: Befund → Auflösung, die dieser Plan umsetzt. Punkte mit „Inhaber“ brauchen eine Bestätigung vor Task 8 bzw. Task 21; bis dahin gilt die Auflösung.
1. **Feldgrenzen weichen ab.** Spec 6.5: `colonyStrength` 0–30, `broodFrames` 0–30, `feedStatus` genau `ausreichend|niedrig|kritisch`. Code: `CreateInspectionDto.colonyStrength` 0–40, `broodFrames` nur `Min(0)`, `feedStatus` ist Enum `ColonyCondition` mit **fünf** Werten (`EXCELLENT GOOD AVERAGE WEAK CRITICAL`, Labels in `FEED_CONDITION_LABELS_DE`). → `FIELD_SPECS` folgt dem DTO (Spec sagt selbst „gleiche Grenzen wie `CreateInspectionDto`“): `colonyStrength` 0–40, `broodFrames` 0–40 (serverseitige Verschärfung, weil das DTO keine Obergrenze hat; Wächter prüft „innerhalb der DTO-Grenzen“ statt „gleich“), `feedStatus` ∈ `ColonyCondition`; der Prompt nennt die Ankerwörter aus `FEED_CONDITION_LABELS_DE` (reichlich, ausreichend, mittel, niedrig, kritisch). Gleiche Werte wie Teil 4 §5.5 (`GOOD/WEAK/CRITICAL`, 0–40).
2. **Wächter „Schema-Enum = DTO-Felder minus Wetter und Temperatur“ ist so nicht erfüllbar.** Das DTO hat zusätzlich `colonyId`, `date`, `broodAmount` (Alt-Text), `applyToColony`, `hiveChange` und bekommt `voiceCaptureId`. → Feste Ausschlussliste `VOICE_EXCLUDED_INSPECTION_FIELDS` mit Begründung je Feld; der Wächter vergleicht DTO-Eigenschaften (class-validator-Metadaten) minus Liste mit den 17 Feldern.
3. **`recordForUserOnce` gibt es nicht.** `NotificationsService` hat `record(input, tx?)` mit dauerhaft eindeutigem `dedupeKey`. → Handler nutzen `record(..., tx)` mit `dedupeKey` `voice-ready:<captureId>` bzw. `voice-failed:<captureId>:<code>`; Kategorie `SUGGESTION` (die Notiz ist ein Vorschlag an den Nutzer; Telegram nur, wenn der Nutzer die Kategorie gewählt hat). Text enthält nur den Volksnamen.
4. **`outbox-claims.ts` passt nicht auf `VoiceCapture`.** Es kennt nur `PENDING/PROCESSING`, eine feste `MAX_ATTEMPTS = 8`, ein festes `STALE_CLAIM_MS = 300000` und CAS auf `claimedAt`; T7 braucht zwei Phasen mit eigenen Status, 3 Versuchen, 30 min bzw. 5 min Stale-Frist und `claimToken` (der Claim wandert über zwei Prozesse). `retryDelayMs` ist nicht parametrierbar. → `retry-policy.ts` bekommt `RetryPolicy` und `retryDelayMsFor(policy, attempts)` (der bestehende `retryDelayMs` delegiert, Verhalten unverändert); neue `src/voice/claims/voice-claims.ts` mit derselben SQL-Form (SKIP LOCKED, `now()`, geschlossene Tabellen-Map) für `VoiceCapture` – Teil 4 trägt `VoiceUtterance` in dieselbe Map ein.
5. **Wartezeiten beim Auswerten:** „höchstens 3 Versuche“ und „30 s / 2 min / 10 min“ – bei 3 Versuchen werden nur zwei Wartezeiten gebraucht. → 3 Versuche bleiben (Entscheidung), Wartezeiten `[30000, 120000]`; 10 min entfällt. `EXTRACT_INVALID` hat laut Spec 10 „einen Wiederholungsversuch“ → zählt als normaler Versuch (passt in die 3).
6. **Ein `attempts`-Zähler für zwei Phasen.** → `ingest` setzt beim Übergang nach `TRANSCRIBED` `attempts = 0`; `retry` setzt ihn ebenfalls auf 0 (Spec 5).
7. **Ton „höchstens 24 h“, aber Löschung nur im täglichen `cleanup` (04:00)** – Ton könnte bis zu 48 h liegen. → `expireVoiceContent(db)` (Ton > 24 h, Inhalte nach `contentExpiresAt`, Inhalte deaktivierter Nutzer) läuft zusätzlich in der `fetch`-Phase jedes CronJob-Laufs (alle 2 min, reine SQL, kein Schlüssel nötig); `cleanup` ruft dieselbe Funktion und löscht Zeilen nach 90 Tagen. Teil 4 hängt dort seine 24-h-Frist für Sätze an.
8. **`VOICE_ENCRYPTION_KEY` nur in `api`, `worker`, `fetch`/`ingest` (Spec 11.3)** – aber `startup-checks.ts` verlangt in Produktion **jeden** Schlüssel aus `ENCRYPTION_KEY_VARIABLES` in **jeder** Rolle (API, Worker, alle Jobs), und alle Backend-Pods binden `apicore-backend-secret` per `envFrom`. → Schlüssel kommt wie MFA/WEBHOOK/BACKUP in `apicore-backend-secret` (`ensure-secrets.sh` erzeugt ihn), und zwar **vor** Rollout-Schritt 1 (sonst startet das neue Image nicht). Spec 14 Schritt 3.2 wird damit Voraussetzung von 4.1.
9. **`ANTHROPIC_API_KEY` „in Produktion Pflicht“ und zugleich „fehlt er, startet der Worker“.** → Nicht Pflicht. Eigenes Secret `apicore-anthropic`, nur im Worker per `secretKeyRef` (`optional: true`), Muster `apicore-telegram`. Startprüfung `assertAnthropicKey(role, env)` wie `assertTelegramToken`: Platzhalter oder falsches Format → Fehler-Log einmal, Auswertung aus (`EXTRACT_DISABLED`), kein Abbruch. API- und Job-Pods lesen den Schlüssel nie.
10. **Gemeinsamer CronJob-Patch ist eine Falle.** `components/backend-roles/kustomization.yaml` patcht alle CronJobs mit Label `app=apicore-job` mit einem Container `job` (Strategic Merge per Name). Der Voice-CronJob hat keinen Container `job` → der Patch **fügt einen hinzu** (Backend-Image ohne Kommando, alle Secrets). → Der Voice-CronJob trägt am CronJob-Objekt `app: apicore-voice-job` (vom Patch nicht getroffen) und nur im Pod-Template `app: apicore-job` (für `NetworkPolicy apicore-jobs`); Härtung, Zeitzone, `Forbid` stehen vollständig im eigenen Manifest; `manifest-policy.test.mjs` prüft, dass er genau drei Container hat.
11. **IndexedDB „Version 4“ ist belegt** (`pending-phenology-reports`, T4). Beute L5 plant v5 (`pending-hive-changes`), Teil 4 danach `voice-sessions`. → T7 nimmt die **nächste freie Version beim Bau** (v5, falls Beute L5 noch nicht gemergt ist, sonst v6); der Upgrade-Zweig prüft `oldVersion < N`. Task 17 schreibt die tatsächliche Nummer in `db.ts` und in den Bericht.
12. **`inspection-scales.ts` gibt es nicht.** Die Richtung der Skalen steckt im Formular (`RatingDirection` in `lib/forms/rating.ts`, je Feld in `inspections/new/page.tsx`). → Eine gemeinsame JSON-Quelle `ApiCore-Backend/test/fixtures/voice/inspection-scales.json` (Feld, gespeicherte Bedeutung von 1 und 5, Ankerwörter); Backend-Prompt liest sie zur Bauzeit über ein generiertes TS-Modul `inspection-scales.ts`, Frontend-Wächter vergleicht sie mit einer neuen Konstante `INSPECTION_SCALE_DIRECTIONS` (`lib/inspections/scales.ts`), die das Formular nutzt.
13. **Rate-Limit „10/min je Nutzer“** – der Throttler zählt nach Client-IP (`ClientIpThrottlerGuard`). → `@Throttle` 10/min (IP) auf dem Upload **plus** Nutzergrenze in der DB (höchstens 10 neue Notizen in 60 s je `ownerUserId`, gleiche Abfrage wie die Tagesgrenze).
14. **Übernahme nur bei `READY` (Spec 6.6) widerspricht dem Ziel „nach dem Speichern kein Inhalt mehr“**: Bei `TRANSCRIBED` (Auswertung aus, Tageslimit), `FAILED` mit Text oder laufender Verarbeitung bliebe der Inhalt bis zu 7 Tage liegen, obwohl der Nutzer gespeichert hat. → Bestätigbar sind alle offenen Status (`QUEUED` … `READY`) und `FAILED`; `CONFIRMED/DISCARDED/EXPIRED` ergeben 409 `VOICE_CAPTURE_GONE`. Laufende Phasen schließen per CAS auf `status` + `claimToken` ab und ändern eine bestätigte Notiz nicht. Gleiches Volk bleibt Pflicht (409). **Inhaber** (Frage A1).
15. **`POST /voice-captures/discard-all`** steht in 8.4, fehlt in der API-Tabelle 5. → wird gebaut (Task 7). **Kennung des Modells** auf der Info-Seite (8.4) und der **Schalter zum Ausblenden** (14.5) brauchen Serverwissen → neuer Endpunkt `GET /voice-captures/config` (`{ recordingEnabled, extractionEnabled, model, retention }`), gesteuert über `VOICE_RECORDING_ENABLED` (ConfigMap, Standard `false`).
16. **JobRun-Flut.** `run-cli.ts` schreibt je Lauf eine `JobRun`-Zeile; zwei Phasen × 720 Läufe/Tag = 1440 Zeilen/Tag, 90 Tage Aufbewahrung. → `cleanup` löscht erfolgreiche `voice-transcribe`-Läufe ohne Arbeit (`result->>'claimed' = '0'` bzw. `'ingested' = '0'`) nach 24 h; Läufe mit Arbeit oder Fehler bleiben 90 Tage.
17. **Netzwerk-Wächter.** `no-network-outside-loops.spec.ts` erlaubt Netz nur in `src/weather/`, `src/telegram/` und Altdateien. → Ausnahme genau für `src/voice/extraction/anthropic-structured-model.ts` und das Paket `@anthropic-ai/sdk`; `src/jobs/**` (also auch `voice-transcribe.job.ts`) und `*.handlers.ts` dürfen es nicht importieren.
18. **Magic Bytes:** `matchesFileSignature` gibt für unbekannte Typen `true` zurück und kennt kein Audio. → Neue strikte Funktion `detectAudioType(buffer): VoiceAudioMime | null` in `src/common/file-signature.ts` (EBML `1A 45 DF A3` → `audio/webm`, `OggS` → `audio/ogg`, `ftyp` an Byte 4 → `audio/mp4`); `matchesFileSignature` bleibt unverändert.
19. **Render-Stufen.** `deploy/k8s/tests/render-stages.mjs` kennt eine feste `ROLLOUT_ORDER`. → Neue Stufe `voice` (nach `api-hpa`) in `render-stages.mjs` und als auskommentierbare Komponente im Overlay.
20. **Kein Playwright im Repo** (Beute-Plan Global Constraints). → Browser-Prüfungen mit chrome-devtools-MCP; Teil 4 §15.3 erwähnt Playwright, das ist dort zu klären, nicht in T7.
---
## Global Constraints
**Ablauf**
- Arbeit auf `main`. Agents committen nicht; der Orchestrator committet einmal je Lieferung (L1…L6) nach den Gates und pusht gebündelt.
- Backend-Gates: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci` (nie `npm run lint`, das Skript hat `--fix`); einzelne e2e `npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand`; alle e2e `npm run test:e2e -- --runInBand`. Frontend: vor Next.js-Code `node_modules/next/dist/docs/` lesen (AGENTS.md); Gates `npm run lint && npm run typecheck && npx vitest run <pfade>`, am Lieferungsende `npm test` und `npm run build`. Manifeste: `node deploy/k8s/tests/manifest-policy.test.mjs` und `render-stages.mjs`. Whisper: `pytest` im Image-Build.
- Die Test-DB ist nicht leer: Tests legen eigene Nutzer/Völker an, räumen `VoiceCapture*` in `beforeEach` auf und prüfen Differenzen.
- Neue Abhängigkeiten: vor der Installation `supply-chain-risk-auditor`; exakt pinnen (`--save-exact`); Ergebnis in `.superpowers/sdd/2026-10-16-t7-sprache/progress.md`.
- `ARCHITECTURE.md`, `TASKS.md`, `docs/pruefung-2026-09.md` trägt nur der Orchestrator ein; Tasks liefern Doku-Text im Bericht. Neue Annahmen bekommen die nächste freie Nummer beim Eintrag (heute zuletzt 100, Beute).
**Migration (eine, additiv, nach `20261018100100_hive_catalog_frame_feeder`; hängt ein anderer Strang davor eine an, rückt der Stempel dahinter)**
| Zeitstempel | Name | Inhalt |
|---|---|---|
| 20261019100000 | `t7_voice_captures` | Enums `VoiceCaptureSource`, `VoiceCaptureStatus`; Modelle `VoiceCapture`, `VoiceCaptureAudio` exakt Spec §3; CHECKs `"audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152`, `"durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240`, `("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL)`, `"source" = 'AUDIO' OR "audioMime" IS NULL`; Recht `voice-captures:use` mit Vergabe an ADMIN, BEEKEEPER, STAFF (`ON CONFLICT DO NOTHING`) |
- Erzeugt mit `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`, von Hand ergänzt; nie `db push`, nie `migrate dev`.
**Fachwerte (exakt aus der Spec)**
- Aufnahme: Mono, `audioBitsPerSecond: 24000`, Typ `audio/webm;codecs=opus`, sonst `audio/mp4`; höchstens 180 s, Hinweis bei 150 s „Noch 30 Sekunden“; Upload ≤ 2 MB (2097152 Byte), genau ein Teil `audio`; Typ nur nach Magic Bytes, sonst 415 `UNSUPPORTED_AUDIO`, zu groß 413 `TOO_LARGE`.
- Grenzen je Nutzer: ≤ 10 offene Notizen (`QUEUED` bis `READY`), ≤ 30 neue je UTC-Tag, ≤ 10 neue je 60 s → 429 `TOO_MANY_OPEN` „Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.“ Volk lebend (`LIVING_COLONY_WHERE`), `inspectionDate` höchstens 7 Tage zurück, nicht in der Zukunft (Berliner Tag). Text-Weg 20–4000 Zeichen nach NFKC und Entfernen von Steuerzeichen.
- Zustände und Fristen: Abschreiben ≤ 3 Versuche, Wartezeiten 5 min / 15 min, Stale 30 min; Auswerten ≤ 3 Versuche, 30 s / 2 min, Stale 5 min. Ton ≤ 24 h (`FAILED/AUDIO_EXPIRED`), Inhalte ≤ 7 Tage (`EXPIRED`, `contentExpiresAt = createdAt + 7 Tage`), Zeile 90 Tage. Alle Fristen in SQL gegen `now()`.
- CronJob `apicore-job-voice-transcribe`: `*/2 * * * *`, `timeZone: Europe/Berlin`, `concurrencyPolicy: Forbid`, `startingDeadlineSeconds: 120`, `activeDeadlineSeconds: 1500`; je Lauf ≤ 5 Aufnahmen und ≤ 600 s Ton; `emptyDir` `medium: Memory`, `sizeLimit: 32Mi`. Ressourcen: `fetch`/`ingest` 50m/500m, 192Mi/512Mi; `whisper` 50m/2000m, 1280Mi/1280Mi, `WHISPER_CPU_THREADS=2`.
- Whisper: Modell `small`, int8, `language='de'`, `beam_size=1`, `vad_filter=True`, `condition_on_previous_text=False`, `initial_prompt` aus `voice-vocabulary.ts`; `NO_SPEECH` bei Sprache ≠ de mit Wahrscheinlichkeit < 0,6 oder Text < 5 Zeichen; `TOO_LONG` bei gemessener Dauer > 240 s.
- Claude: `VOICE_MODEL` Standard `claude-sonnet-5-5` (Option für Kosten: `claude-haiku-5-5`, Option bei Qualitätsmangel: `claude-opus-5-5`), Kennungen nur in `voice-config.ts`; `max_tokens` 2000, `output_config: { effort: 'low', format: { type: 'json_schema', schema } }`, nicht streamend, Zeitlimit 60 s, 2 SDK-Wiederholungen, kein `tool_choice`, kein Prefill, keine Sampling-Parameter; `fallbacks: "default"` mit Beta-Header `server-side-fallback-2026-07-01` nur, wenn die aktuelle Anthropic-Doku es beim Bau bestätigt (sonst weglassen, Vermerk im Bericht). SDK-Debug-Log aus. `VOICE_DAILY_EXTRACTIONS` Standard 60 (Betrieb gesamt, UTC-Tag).
- Prüfung: Zitat ≤ 120 Zeichen, normalisiert (NFKC, Kleinbuchstaben, Nicht-Buchstaben/Ziffern → Leerzeichen, Mehrfach-Leerzeichen zusammengefasst) muss im normalisierten Transkript stehen; Freitext 1–500 Zeichen nach Entfernen von Steuerzeichen und `<`/`>`, länger gekürzt und markiert; `findings` ≤ 20, `unclear` ≤ 5; pro Feld ein Vorschlag, der spätere gewinnt, der frühere als `previous`.
- Rechte: `voice-captures:use` (Gruppe „Sprache“, `sortOrder` 4000, höchster vorhandener Wert ist 3900); jeder Endpunkt zusätzlich `inspections:create`; `GET /voice-captures/stats` nur `system:manage`. Fremde Ids → 404. Antworten `Cache-Control: no-store`.
- Ereignisse: `VoiceCaptureReady { captureId, colonyId }`, `VoiceCaptureFailed { captureId, code }`, `AggregateType` + `'VoiceCapture'`; nie Text; nie Webhooks.
- Backup: `VoiceCapture`, `VoiceCaptureAudio` in `MODELS_EXCLUDED_FROM_BACKUP` mit Begründung „flüchtig, personenbezogen, nach Restore nicht mehr zustellbar“.
- Fehlercodes (fest, `VOICE_ERROR_CODES`): `UNSUPPORTED_AUDIO`, `TOO_LARGE`, `TOO_MANY_OPEN`, `NO_SPEECH`, `TOO_LONG`, `TRANSCRIBE_FAILED`, `AUDIO_EXPIRED`, `EXTRACT_DISABLED`, `EXTRACT_RETRY`, `EXTRACT_FAILED`, `DAILY_LIMIT`, `EXTRACT_INVALID`, `REFUSED`, `VOICE_CAPTURE_GONE`.
**Projektregeln**
- Sicherheit vor Bequemlichkeit. Inhalt (Ton, Text, Zitat, KI-Antwort) nie in Logs, Fehlertexten, Ereignissen, Audit, Metriken, `lastErrorCode`; Fehler nur über `loggableError()`.
- Datensatz, Audit, Ereignis in einer Transaktion; Handler schreiben nur über `tx`, ohne Netz (`handler-contract.spec.ts` deckt `voice.handlers.ts` ab).
- Read-modify-write unter Zeilensperre: `VoiceCapture` kommt ans Ende von `LOCKABLE_TABLES` (nach `ComplianceAcknowledgement`); Durchsicht sperrt (falls nötig) `Colony`, dann `VoiceCapture`.
- Worker und Jobs nutzen `JobPermissionsModule`; Module mit Handlern werden von `AppModule` **und** `WorkerModule` importiert. Der Anthropic-Adapter wird nur im Worker-Modul bereitgestellt.
- UI deutsch, kurze Sätze, Zustand nie nur über Farbe, Ziele ≥ 56 px (Aufnahmetaste 72 px, Diktat-Knopf 44 px laut Spec 8.3), WCAG 2.2 AA, `prefers-reduced-motion`. Code, Testnamen, Log-Texte englisch.
---
## Review Focus
1. **Inhalt leckt:** Log-Mitschnitt-Tests in Tasks 7, 11, 15 (`never logs the transcript`), Ereignis-Payloads nur Ids (Task 9), Whisper-Container ohne Secrets/Netz (Task 13 Policy-Test), `VoiceCapture` nicht in Suche/Export/Backup (Task 4).
2. **Löschung beweisbar:** `confirms the voice capture and wipes its content in the same transaction` (Task 8), `expires audio after 24 h within one CronJob run` (Task 11), Discard/Discard-all (Task 7), e2e Ende-zu-Ende (Task 23).
3. **Erfundenes oder Fremdes kommt nie ins Formular:** `validateSuggestions` verwirft Zitat-lose, fremde Felder, Werte außerhalb `FIELD_SPECS` (Task 14); Formular belegt kein Feld ohne Klick (Task 20).
4. **Doppelarbeit/verlorene Claims:** `fetch claims disjoint sets under SKIP LOCKED`, `ingest does nothing when the claim token no longer matches`, `a confirmed capture is not overwritten by a late ingest or extraction` (Tasks 10, 11, 15).
5. **Offline-Warteschlange bricht nichts:** DB-Upgrade behält alle Speicher (Task 17), fremde Konten senden nie, Ton im Gerät nach 2xx weg, 4xx bleibt als `lastError` (Task 18).
---
## Anschluss Teil 4 (was T7 so schneidet, dass Teil 4 es nur erweitert)
| T7-Baustein | Schnittstelle | Teil-4-Nutzung (Spec Teil 4) |
|---|---|---|
| `lib/voice/speech-support.ts` | `detectSpeechSupport()`, `createRecognition(options)` mit `continuous`, `maxAlternatives`, `processLocally`, `phrases?` | Erkennungs-Schleife §4.2/4.3 baut darauf; T7 nutzt `continuous: false` |
| `lib/voice/consent.ts` | Cloud-Einwilligung, Aufnahmehinweis | §4.1, §12.1 |
| `lib/voice/recorder.ts` | `startRecording({ maxSec, warnAtSec })` | Stücke zu 180 s §10.1 |
| `pending-voice` | Eintrag `StoredVoiceNote`, Felder additiv | `sessionId`, `chunkIndex`, `isLastChunk`, `context` §10.1 |
| `src/voice/core/voice-crypto.ts` | `sealVoiceText/openVoiceText/sealVoiceBytes/openVoiceBytes(…, aad)` | `VoiceUtterance.textEnc`, `VoiceSession.resultEnc` |
| `src/voice/core/transcript.ts` | `TranscriptPayload { v: 1, text, durationSec?, segments? }` | Stücke zusammenfügen über Segmente §10.3 |
| Whisper-Ausgabe | JSON mit `segments: [{ start, end, text }]` schon in T7 | §7.4 („bekommt `segments`“ entfällt dort) |
| `src/voice/core/voice-text.ts` | `normalizeInputText`, `normalizeForQuote`, `quoteInText`, `sanitizeFreeText` | §8.4 Schritte 3 und 5 |
| `src/voice/core/inspection-field-specs.ts` | `FIELD_SPECS`, `checkFieldValue` | §8.4 Schritt 1 (ergänzt `FRAME_FIELD_SPECS`) |
| `src/voice/core/voice-vocabulary.ts` + `test/fixtures/voice/vocabulary.json` | eine Quelle | `phrases`, Prompt, Hörfehler |
| `src/voice/claims/voice-claims.ts` | `VOICE_CLAIM_TABLES` (geschlossene Map), `RetryPolicy` je Phase | `VoiceUtterance` als zweite Tabelle |
| `src/voice/extraction/structured-model.port.ts` | `StructuredModel.extract({ model, system: SystemBlock[], userText, schema, maxTokens, timeoutMs, maxRetries })`, `SystemBlock.cache?: '5m' \| '1h'`, `TokenUsage` mit Cache-Zählern | Schleife `voice-live`, 1-h-Cache, Haiku |
| `src/voice/voice-config.ts` | Modell-Kennungen an einer Stelle | `VOICE_LIVE_MODEL`, `VOICE_LIVE_FALLBACK_MODEL` |
| `VoiceConfirmationService.confirmIn(tx, link, ctx)` | `VoiceLink = { kind: 'capture'; id }` (diskriminierte Union) | `{ kind: 'session'; id }` §9.3 |
| `src/voice/voice-retention.ts` | `expireVoiceContent(db)` | 24-h-Frist der Sätze §13 |
| `components/voice/voice-suggestion.tsx` | generisch `{ fieldLabel, valueText, quote, chips, onTakeOver, onEdit }` | Prüfansicht §10.4 |
| `WAKE_CHANNELS.voiceExtraction` | eigenes Muster | `voiceLive` daneben |
---
## Abhängigkeiten und Wellen
| Welle | Tasks (parallel, disjunkte Dateien) | wartet auf |
|---|---|---|
| 1 | 1, 4 | – (1 ist reines Frontend) |
| 2 | 2, 5, 6, 10 | 2 → 1; 5, 6, 10 → 4 |
| 3 | 3, 7, 12 | 3 → 2; 7 → 5, 6; 12 → 6 |
| 4 | 8, 9, 11, 14 | 8 → 7; 9 → 7; 11 → 10, 12; 14 → 6 |
| 5 | 13, 15, 17 | 13 → 11, 12; 15 → 9, 10, 14; 17 → 1 |
| 6 | 16, 18, 19 | 16 → 11, 15; 18 → 7, 17; 19 → 18 |
| 7 | 20, 21, 22 | 20 → 8, 19; 21 → 3, 20; 22 → 7, 19 |
| 8 | 23, 24 | alle |
| – | 25 Abschluss und Rollout (Orchestrator) | 23, 24 |
Geteilte Dateien (je Welle höchstens ein Task): `prisma/schema.prisma`, `row-lock.ts`, `permission-catalog.ts`, `backup.service.ts`: nur 4. `key-ring.ts`, `startup-checks.ts`: nur 5. `domain-event-types.ts`: nur 9. `worker-runtime.service.ts`, `worker-wakeup.ts`, `worker.module.ts`: nur 15. `job-names.ts`, `jobs.module.ts`: nur 11; `cleanup.job.ts`: nur 16. `inspections.service.ts`, `create-inspection.dto.ts`: nur 8. `app.module.ts`: 7 (W3), 9 (W4, nur falls das Handler-Modul neu ist – sonst 7). Frontend `db.ts`: nur 17; `sync.ts`, `pending-queue-sheet.tsx`, `offline-band.tsx`: nur 18; `inspections/new/page.tsx`: 20 (W7), 21 erweitert in derselben Welle **nicht** – 21 liefert Komponenten, 20 bindet sie ein (21 → 20 Reihenfolge innerhalb W7: 20 zuerst). `nav-config.ts`, `quick-actions.ts`: nur 19.
---
## Lieferung L1 – Stufe 1 „Diktat“ (nur Frontend, kann vor allem anderen live)
### Task 1: Spracherkennungs-Kapsel und Einwilligung
**Files**
- Create: `ApiCore-Frontend/src/lib/voice/speech-support.ts`, `consent.ts`, `vocabulary.ts` (+ Specs)
- Create: `ApiCore-Backend/test/fixtures/voice/vocabulary.json` (Fachwortliste, Spec 6.3: Königin, Stifte, Weiselzellen, Brutwaben, Futterkranz, Honigraum, Varroa, Schwarmstimmung, sanftmütig, Wabensitz, Drohnenbrut und weitere)
- Test: `speech-support.spec.ts`, `consent.spec.ts`, `vocabulary.spec.ts`
**Interfaces**
```ts
export type LocalAvailability = 'available' | 'downloadable' | 'unavailable' | 'unknown';
export interface SpeechSupport { api: boolean; local: LocalAvailability; cloudAllowed: boolean; isInstalledIosPwa: boolean }
export function detectSpeechSupport(win?: Window): Promise<SpeechSupport>;
export interface RecognitionOptions { lang: 'de-DE'; continuous: boolean; interimResults: boolean; maxAlternatives: number; processLocally: boolean; phrases?: readonly string[] }
export interface RecognitionResultEvent { finalText: string; interimText: string; alternatives: string[] }
export interface RecognitionHandle { start(): void; stop(): void; abort(): void; on(event: 'result', fn: (e: RecognitionResultEvent) => void): void; on(event: 'end' | 'audiostart', fn: () => void): void; on(event: 'error', fn: (code: string) => void): void }
export function createRecognition(options: RecognitionOptions, win?: Window): RecognitionHandle | null; // null ohne API; phrases nur gesetzt, wenn die Eigenschaft existiert
// consent.ts (localStorage, jede Lese-/Schreiboperation in try/catch, Standard false)
export function getCloudRecognitionConsent(): boolean; export function setCloudRecognitionConsent(value: boolean): void;
export function hasSeenRecordingNotice(): boolean; export function markRecordingNoticeSeen(): void;
export const VOICE_VOCABULARY: readonly string[]; // aus der JSON-Fixture gespiegelt
```
- [ ] **Schritt 1: Failing Tests**: `returns api false without SpeechRecognition and webkitSpeechRecognition`; `reports local available via SpeechRecognition.available({ langs: ['de-DE'], processLocally: true })`; `reports unknown when available() is missing`; `cloudAllowed follows the stored consent and defaults to false`; `createRecognition sets processLocally only when requested and phrases only when supported`; `consent survives a throwing localStorage`; `VOICE_VOCABULARY equals the shared fixture` (`it.skipIf(!existsSync(fixture))`, Muster `placeholder-keys.spec.ts`).
- [ ] **Schritt 2:** `npx vitest run src/lib/voice` → FAIL. **Schritt 3: Implementieren.** **Schritt 4:** → PASS. Kein Commit.
### Task 2: Diktat-Knopf an den Freitextfeldern
**Files**
- Create: `ApiCore-Frontend/src/components/voice/dictation-button.tsx`, `use-dictation.ts` (+ Specs)
- Modify: `ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx` (Knopf rechts an `notes`, `healthIssues`, `varroaIssues`, `measures`)
- Test: `dictation.spec.tsx`, `inspections/new/page.spec.tsx` (Erweiterung)
**Interfaces**
- `useDictation({ onFinal(text: string): void }): { state: 'idle' | 'listening' | 'unavailable' | 'needs-consent' | 'error'; interim: string; start(): void; stop(): void }` – `lang 'de-DE'`, `continuous: false`, `interimResults: true`, `processLocally: true` bei `local = 'available'`, sonst nur mit Einwilligung.
- `<DictationButton fieldId fieldLabel onAppend(text) />`: 44 px, Beschriftung „Diktieren“, während des Diktats „Hört zu …“ plus grauer Zwischentext und Stopp-Knopf; Zustand als Text (offline: „Nur auf dem Gerät“); erscheint nicht, wenn `api` false oder weder Gerät noch Einwilligung; installierte iOS-PWA: Hinweis „Hier lieber eine Sprachnotiz aufnehmen“ statt Knopf.
- [ ] **Schritt 1: Failing Tests**: `hides the button when the browser has no speech recognition`; `appends and never overwrites` (Feld „Alt“ + Ergebnis „neu“ → „Alt neu“); `offers cloud recognition only after consent`; `shows the state as text, not only colour`; `stops on the stop button and releases the recognizer`.
- [ ] **Schritt 2:** FAIL. **Schritt 3: Implementieren.** **Schritt 4:** PASS; Gates Frontend.
### Task 3: Seite „Sprache & Datenschutz“ (Grundstock)
**Files**
- Create: `ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx`, `voice-privacy-text.ts` (+ Spec)
- Modify: `ApiCore-Frontend/src/app/(app)/settings/layout.tsx` bzw. die Einstellungs-Navigation (Eintrag „Sprache & Datenschutz“, für alle angemeldeten Nutzer)
- Test: `settings/voice/page.spec.tsx`
**Interfaces**
- Abschnitte nach Spec 8.4 und 9.5: was mit Ton und Text passiert, Fristen (24 h / 7 Tage / 90 Tage), wohin (Cluster / Anthropic / bei Cloud-Erkennung Google bzw. Apple), Schalter „Browser-Cloud-Erkennung erlauben“ (Standard aus), Platzhalter für Name und Anschrift aus `SiteSettings`. Modellkennung und „Alle meine Sprachnotizen löschen“ folgen in Task 22 (brauchen Backend).
- [ ] **Schritt 1: Failing Tests**: `the cloud switch is off by default and stores the consent`; `names the retention periods in words`; `axe finds nothing`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS.
- [ ] **Schritt 5: Browser-Prüfung** (chrome-devtools-MCP, 390 px und 1366 px, Tastatur, Fokus sichtbar, Chrome-Diktat mit echtem Mikrofon wenn verfügbar). Orchestrator committet `feat: browser dictation for inspection text fields (T7 L1)`.
---
## Lieferung L2 – Datenmodell, Schlüssel, API, Übernahme (Backend)
### Task 4: Schema, Migration, Recht, Sperre, Backup-Ausnahme, Wächter
**Files**
- Modify: `ApiCore-Backend/prisma/schema.prisma` (Spec §3 exakt; Gegenrelationen `User.voiceCaptures`, `Colony.voiceCaptures`), `src/auth/permissions/permission-catalog.ts` (`...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000)`), `src/prisma/row-lock.ts` (+ Spec; `VoiceCapture: lockable('VoiceCapture')` am Ende), `src/backup/backup.service.ts` (+ Spec; Ausnahmeliste)
- Create: `prisma/migrations/20261019100000_t7_voice_captures/migration.sql`
- Test: neu `test/t7-schema.e2e-spec.ts`, `test/voice-permissions.e2e-spec.ts`; Erweiterung `test/search.e2e-spec.ts` bzw. Export-Spec (Wächter)
**Interfaces**
- Prisma-Modelle `VoiceCapture`, `VoiceCaptureAudio`, Enums wie Spec §3. Keine Spalte für Teil 4 (`sessionId`, `chunkIndex` … kommen dort additiv).
- [ ] **Schritt 1: Failing Tests**
```ts
// t7-schema.e2e-spec.ts
it('rejects audioBytes 0 and 2097153', ...);
it('rejects durationSec 241', ...);
it('rejects CONFIRMED without inspectionId and inspectionId on a non-confirmed row', ...);
it('rejects audioMime on a TEXT capture', ...);
it('deleting a capture cascades to its audio row', ...);
// voice-permissions.e2e-spec.ts
it('ADMIN, BEEKEEPER and STAFF hold voice-captures:use', ...);
it('READONLY and API_CLIENT do not', ...);
// row-lock.spec.ts
it('keeps the map in lock order with VoiceCapture last', ...);
// backup.service.spec.ts
it('excludes VoiceCapture and VoiceCaptureAudio with a reason', ...);
// Wächter
it('VoiceCapture appears in no search provider and no export', ...);
```
- [ ] **Schritt 2:** FAIL. **Schritt 3: Implementieren** (SQL aus `migrate diff`, CHECKs und Rechte-INSERT von Hand). **Schritt 4:** PASS; Drift-Check `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0; Test-DB migrieren (Muster T6 Global Constraints). Kein Commit.
### Task 5: Schlüsselbund-Zweck `voice`, Verschlüsselungs-Hilfen, Startprüfungen
**Files**
- Modify: `src/common/crypto/key-ring.ts` (+ Spec), `src/runtime/startup-checks.ts` (+ Spec), `src/jobs/rekey.job.ts` (+ Spec), `deploy/k8s/base/backend/secret.example.yaml`, `deploy/k8s/scripts/ensure-secrets.sh`
- Create: `src/voice/core/voice-crypto.ts` (+ `voice-crypto.spec.ts`), `src/voice/voice-config.ts` (+ Spec), `src/voice/anthropic-config.ts` (+ Spec)
**Interfaces**
```ts
// key-ring.ts
ENCRYPTION_KEY_VARIABLES.voice = 'VOICE_ENCRYPTION_KEY'; type SecretPurpose = 'mfa' | 'webhook' | 'voice';
// voice-crypto.ts (AES-256-GCM über gcmSeal/gcmOpen, Schlüssel-Id vorn, AAD bindet an die Zeile)
export function sealVoiceText(plaintext: string, aad: string): string; // `k…:` Format, AAD z. B. 'capture:<id>:transcript'
export function openVoiceText(stored: string, aad: string): string;
export function sealVoiceBytes(plaintext: Buffer, aad: string): Buffer; // [1 Byte Version][8 Byte Key-Id][12 IV][16 Tag][Daten]
export function openVoiceBytes(stored: Buffer, aad: string): Buffer;
export function isVoiceSealedWithCurrentKey(stored: string | Buffer): boolean; // für rekey
// voice-config.ts
export const VOICE_MODELS = ['claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-opus-5-5'] as const;
export const DEFAULT_VOICE_MODEL = 'claude-sonnet-5-5';
export interface VoiceConfig { model: (typeof VOICE_MODELS)[number]; dailyExtractions: number; recordingEnabled: boolean; whisperModel: 'small' | 'medium' }
export function voiceConfig(env?: NodeJS.ProcessEnv): VoiceConfig; // unbekanntes Modell -> Start-Fehler mit Variablennamen; dailyExtractions 1..10000, Standard 60
// anthropic-config.ts (Muster telegram-config.ts)
export function anthropicApiKey(env?: NodeJS.ProcessEnv): string | null; // null bei leer, Platzhalter ^change-me oder Format != /^sk-ant-[A-Za-z0-9_-]{20,}$/
export function assertAnthropicKey(role: ApicoreRole, env?: NodeJS.ProcessEnv): void; // nur worker/all; loggt einmal, wirft nie
```
- `rekey`: reseal offener `transcriptEnc`/`suggestionEnc` und `VoiceCaptureAudio.dataEnc` (nur Status ≠ `CONFIRMED/DISCARDED/EXPIRED`), Zähler im `JobResult`.
- `ensure-secrets.sh`: `VOICE_ENCRYPTION_KEY random` in `apicore-backend-secret`; Kommentar: vor dem T7-Backend-Image ausführen (Widerspruch 8). `secret.example.yaml`: Schlüssel und Hinweis auf `apicore-anthropic` (nur Worker, `secretKeyRef`, `optional: true`, Muster Telegram).
- [ ] **Schritt 1: Failing Tests**: `audio and transcript round-trip through the key ring`; `fail with the wrong key`; `fail when the aad names another capture`; `use the voice purpose key, not the JWT secret`; `production requires VOICE_ENCRYPTION_KEY distinct from the others` (startup-checks); `rejects a change-me VOICE_ENCRYPTION_KEY in production`; `assertAnthropicKey never throws and disables on a placeholder`; `api and job roles skip the Anthropic check`; `voiceConfig rejects an unknown model id`; `rekey reseals open voice content and skips confirmed rows`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 6: Gemeinsamer Kern (Text, Zitat, Felder, Transkript, Fachwörter, Skalen)
**Files**
- Create: `src/voice/core/voice-text.ts`, `inspection-field-specs.ts`, `transcript.ts`, `voice-vocabulary.ts`, `inspection-scales.ts`, `voice-errors.ts` (+ Specs)
- Create: `ApiCore-Backend/test/fixtures/voice/inspection-scales.json`
- Test: `voice-text.spec.ts`, `inspection-field-specs.spec.ts`, `transcript.spec.ts`, `voice-vocabulary.spec.ts`
**Interfaces**
```ts
// voice-text.ts
export function normalizeInputText(text: string): string; // NFKC, Steuerzeichen außer \n entfernt, trim
export function normalizeForQuote(text: string): string; // NFKC, lower, [^\p{L}\p{N}] -> ' ', Leerzeichen zusammengefasst
export function quoteInText(quote: string, text: string): boolean;
export function sanitizeFreeText(text: string, max: number): { text: string; truncated: boolean } | null; // null bei leer
// inspection-field-specs.ts
export const INSPECTION_VOICE_FIELDS = ['colonyStrength','broodFrames','broodPattern','openBroodPresent','cappedBroodPresent','eggsPresent','queenSeen','queenCellsPresent','swarmMood','gentleness','combBehavior','feedStatus','honeySuperStatus','healthIssues','varroaIssues','measures','notes'] as const;
export type InspectionVoiceField = (typeof INSPECTION_VOICE_FIELDS)[number];
export type FieldSpec = { kind: 'int'; min: number; max: number } | { kind: 'bool' } | { kind: 'enum'; values: readonly string[] } | { kind: 'text'; max: 500 };
export const FIELD_SPECS: Readonly<Record<InspectionVoiceField, FieldSpec>>;
export const VOICE_EXCLUDED_INSPECTION_FIELDS: Readonly<Record<string, string>>; // colonyId, date, weather, temperature, broodAmount, applyToColony, hiveChange, voiceCaptureId -> Begründung
export function checkFieldValue(field: string, value: unknown): { ok: true; field: InspectionVoiceField; value: number | boolean | string; truncated: boolean } | { ok: false };
// transcript.ts
export interface TranscriptSegment { start: number; end: number; text: string }
export interface TranscriptPayload { v: 1; text: string; durationSec?: number; segments?: TranscriptSegment[] }
export function sealTranscript(captureId: string, payload: TranscriptPayload): string; // aad 'capture:<id>:transcript'
export function openTranscript(captureId: string, stored: string): TranscriptPayload; // prüft v === 1
// voice-vocabulary.ts
export const VOICE_VOCABULARY: readonly string[]; export function whisperInitialPrompt(): string; // ≤ 224 Tokens grob: ≤ 800 Zeichen
// inspection-scales.ts (generiert aus der JSON-Fixture per Import mit resolveJsonModule)
export interface ScaleSpec { field: 'broodPattern' | 'swarmMood' | 'gentleness' | 'combBehavior'; storedOne: string; storedFive: string; anchors: Record<string, 1 | 2 | 3 | 4 | 5> }
export const INSPECTION_SCALES: readonly ScaleSpec[];
// voice-errors.ts
export const VOICE_ERROR_CODES = [...] as const; export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number];
```
- [ ] **Schritt 1: Failing Tests**: `matches the quote across punctuation and case`; `does not match a quote with a changed number`; `sanitizeFreeText strips control characters and angle brackets and shortens to 500`; `FIELD_SPECS covers exactly the DTO fields minus the excluded list` (Metadaten von `CreateInspectionDto` über `getMetadataStorage()`); `FIELD_SPECS bounds lie within CreateInspectionDto` (Min/Max je Feld); `feedStatus accepts every ColonyCondition and nothing else`; `rejects weather and temperature`; `transcript payload round-trips with segments`; `rejects a payload with another version`; `VOICE_VOCABULARY equals the shared fixture`; `whisperInitialPrompt stays below 800 characters`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 7: Sprachnotizen-API (Upload, Text, Liste, Abruf, Wiederholen, Verwerfen, Konfiguration, Zähler)
**Files**
- Create: `src/voice/voice.module.ts`, `src/voice/captures/voice-captures.controller.ts`, `voice-captures.service.ts`, `voice-captures-stats.service.ts`, `voice-capture-view.ts`, `dto/upload-voice-capture.dto.ts`, `dto/submit-voice-text.dto.ts`, `dto/list-voice-captures.query.ts` (+ Specs)
- Modify: `src/common/file-signature.ts` (+ Spec: `detectAudioType`), `src/app.module.ts`, `src/all-in-one.module.ts` (falls dort gelistet)
- Test: `voice-captures.service.spec.ts`, `file-signature.spec.ts`, `test/authorization-coverage.e2e-spec.ts` (läuft grün)
**Interfaces**
```ts
export type VoiceAudioMime = 'audio/webm' | 'audio/ogg' | 'audio/mp4';
export function detectAudioType(buffer: Buffer): VoiceAudioMime | null;
export interface VoiceCaptureListItem { id; colonyId; colonyName; status: VoiceCaptureStatus; source; createdAt: string; lastErrorCode: VoiceErrorCode | null; contentExpiresAt: string }
export interface VoiceCaptureDetail extends VoiceCaptureListItem { inspectionDate: string; transcript: string | null; suggestions: VoiceSuggestionView[] | null; unclear: string[]; dropped: number }
export interface VoiceSuggestionView { field: InspectionVoiceField; value: number | boolean | string; quote: string; corrected: boolean; truncated: boolean; previous?: { value: number | boolean | string; quote: string } }
export interface VoiceClientConfig { recordingEnabled: boolean; extractionEnabled: boolean; model: string; retention: { audioHours: 24; contentDays: 7; rowDays: 90 } }
VoiceCapturesService.upload(userId: string, file: Express.Multer.File, dto: UploadVoiceCaptureDto): Promise<{ id: string; status: 'QUEUED' }>;
VoiceCapturesService.submitText(userId: string, dto: SubmitVoiceTextDto): Promise<{ id: string; status: 'TRANSCRIBED' }>; // pg_notify voice_extraction im selben tx
VoiceCapturesService.list(userId: string, status?: VoiceCaptureStatus): Promise<VoiceCaptureListItem[]>;
VoiceCapturesService.get(userId: string, id: string): Promise<VoiceCaptureDetail>; // transcript/suggestions bei READY, TRANSCRIBED, FAILED mit Inhalt
VoiceCapturesService.retry(userId: string, id: string): Promise<VoiceCaptureListItem>; // nur FAILED mit Ton/Text und contentExpiresAt > now()
VoiceCapturesService.discard(userId: string, id: string): Promise<void>; // idempotent, löscht Audio-Zeile, transcriptEnc, suggestionEnc
VoiceCapturesService.discardAll(userId: string): Promise<{ discarded: number }>;
VoiceCapturesStatsService.stats(): Promise<{ byStatus: Record<string, number>; byError: Record<string, number>; tokens: { input: number; output: number }; byModel: Record<string, number> }>; // letzte 7 Tage, ohne Nutzerbezug
// Routen (alle @RequirePermission('voice-captures:use') + inspections:create, außer stats: system:manage; Header Cache-Control: no-store)
// POST /voice-captures (multipart, FileInterceptor('audio', { storage: memoryStorage(), limits: { fileSize: 2097152, files: 1, fields: 4 } }), @Idempotent(), @Throttle 10/min)
// POST /voice-captures/text (@Idempotent()), GET /voice-captures?status=, GET /voice-captures/config, GET /voice-captures/stats,
// GET /voice-captures/:id, POST /voice-captures/:id/retry, POST /voice-captures/:id/discard, POST /voice-captures/discard-all
```
- Upload: Volk lebend, Datum-Regel, Nutzergrenzen (offen, Tag, 60 s) in **einer** Transaktion unter `pg_advisory_xact_lock(hashtext('voice-captures:' || userId))`, Ton mit `sealVoiceBytes(…, 'capture:<id>:audio')` in `VoiceCaptureAudio`, Audit `CREATE` ohne Inhalt. Keine Datei auf der Platte.
- Statische Pfade (`config`, `stats`, `discard-all`, `text`) vor `:id` registrieren.
- [ ] **Schritt 1: Failing Tests**: `rejects foreign ids with 404`; `limits open captures`; `limits per day`; `limits ten per minute per user`; `checks magic bytes not the declared type` (WebM-Bytes mit `Content-Type: image/png` → angenommen als `audio/webm`; PNG-Bytes mit `audio/webm` → 415); `rejects a file above 2 MB with 413`; `rejects a dead colony`; `rejects an inspectionDate in the future or older than 7 days`; `discard deletes audio, transcript and suggestion`; `discard-all touches only the caller's captures`; `retry resets attempts and only works on FAILED with content`; `list never contains transcript or suggestions`; `stats has no user ids`; `never logs the transcript` (Logger-Spion über den Text-Weg); `detectAudioType knows EBML, OggS and ftyp and nothing else`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 8: Übernahme in `POST /inspections`
**Files**
- Create: `src/voice/captures/voice-confirmation.service.ts` (+ Spec)
- Modify: `src/inspections/dto/create-inspection.dto.ts` (`voiceCaptureId?: string`, `@Matches(/^[A-Za-z0-9_-]{1,40}$/)`), `src/inspections/inspections.service.ts` (`create`), `src/inspections/inspections.module.ts` (importiert `VoiceModule` bzw. ein schlankes `VoiceConfirmationModule` ohne Netz-Provider)
- Test: `inspections.service.spec.ts` (Erweiterung), `voice-confirmation.service.spec.ts`
**Interfaces**
```ts
export type VoiceLink = { kind: 'capture'; id: string }; // Teil 4 ergänzt { kind: 'session'; id }
export interface VoiceConfirmContext { userId: string; colonyId: string; inspectionId: string }
VoiceConfirmationService.confirmIn(tx: Prisma.TransactionClient, link: VoiceLink, ctx: VoiceConfirmContext): Promise<void>;
// sperrt VoiceCapture FOR UPDATE; fremd oder fehlend -> 409 VOICE_CAPTURE_GONE (nicht 404: der Client wiederholt ohne Id);
// anderes Volk -> 409 { code: 'VOICE_CAPTURE_COLONY_MISMATCH' }; CONFIRMED/DISCARDED/EXPIRED -> 409 VOICE_CAPTURE_GONE;
// sonst: status CONFIRMED, inspectionId, transcriptEnc/suggestionEnc NULL, Audio-Zeile gelöscht, claimToken NULL, contentDeletedAt now(), Audit UPDATE ohne Inhalt
```
- `InspectionsService.create`: `voiceCaptureId` aus `data` herauslösen (wird **nicht** an `Inspection` gespeichert), nach dem Insert in derselben Transaktion `confirmIn`. Nicht bestätigbare Notiz → ganze Transaktion bricht ab, keine Durchsicht (Spec 6.6).
- [ ] **Schritt 1: Failing Tests**: `confirms the voice capture and wipes its content in the same transaction`; `409 VOICE_CAPTURE_GONE for an expired, discarded or foreign capture and no inspection row`; `409 when the capture belongs to another colony`; `rolls back the confirmation when the inspection fails`; `confirms a TRANSCRIBED capture too` (Widerspruch 14); `voiceCaptureId is never written to the inspection`; `without voiceCaptureId the existing specs stay green`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS; `npx jest src/inspections src/voice`. Kein Commit.
### Task 9: Ereignisse, Handler, Meldungen
**Files**
- Modify: `src/domain-events/domain-event-types.ts` (Payloads, `CATALOG`, `AggregateType`), `src/domain-events/domain-events.service.spec.ts` (Typliste), `src/webhooks/webhook.handlers.spec.ts` (nicht weitergeleitet)
- Create: `src/voice/voice.handlers.ts` (+ Spec), Eintrag in `voice.module.ts`; `WorkerModule` importiert ein Modul mit den Handlern (Task 15 trägt es ein, hier nur `VoiceHandlersModule` bereitstellen)
- Test: `voice.handlers.spec.ts`, `handler-contract.spec.ts` (läuft grün)
**Interfaces**
- `@OnDomainEvent(['VoiceCaptureReady'], 'voice.notify-ready')`: `notifications.record({ userId: owner, priority: 'HINWEIS', category: 'SUGGESTION', message: 'Sprachnotiz für {Volk} ist fertig', relatedType: 'VoiceCapture', relatedId: captureId, link: '/inspections/new?colonyId=…&voice=…', requiredPermissions: ['voice-captures:use'], dedupeKey: 'voice-ready:<captureId>' }, tx)`.
- `@OnDomainEvent(['VoiceCaptureFailed'], 'voice.notify-failed')`: `WARNUNG`, „Sprachnotiz für {Volk} konnte nicht ausgewertet werden.“ plus je Code ein Satz aus `VOICE_ERROR_TEXTS_DE` (z. B. `NO_SPEECH` „Es war nichts zu verstehen. Bitte noch einmal aufnehmen.“), Link `/voice`, `dedupeKey: 'voice-failed:<captureId>:<code>'`.
- Fehlschlag der Abschrift/Auswertung wegen `EXTRACT_DISABLED` meldet zusätzlich an `system:manage` (`recordForPermissionOnce`, ohne Nutzerbezug im Text).
- [ ] **Schritt 1: Failing Tests**: `writes one HINWEIS for the owner only once per capture`; `the failure text names the next step per code`; `payloads carry only ids and the code`; `neither event is forwarded to webhooks`; `the handler reads the colony name from the database, not from the payload`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Orchestrator prüft L2-Gates und committet `feat: voice capture data model, API and inspection confirmation (T7 L2)`.
---
## Lieferung L3 – Abschreiben im Cluster
### Task 10: Wiederholregel und Voice-Claims
**Files**
- Modify: `src/common/retry-policy.ts` (+ Spec)
- Create: `src/voice/claims/voice-claims.ts`, `voice-phases.ts` (+ Specs)
- Test: `retry-policy.spec.ts`, `voice-claims.spec.ts`, neu `test/voice-claims.e2e-spec.ts`
**Interfaces**
```ts
// retry-policy.ts
export interface RetryPolicy { delaysMs: readonly number[]; maxAttempts: number; staleClaimMs: number }
export const DEFAULT_RETRY_POLICY: RetryPolicy; // = bisherige Konstanten
export function retryDelayMsFor(policy: RetryPolicy, attempts: number): number; // retryDelayMs(a) === retryDelayMsFor(DEFAULT_RETRY_POLICY, a)
// voice-phases.ts
export const VOICE_PHASES = {
transcribe: { from: 'QUEUED', working: 'TRANSCRIBING', policy: { delaysMs: [300_000, 900_000], maxAttempts: 3, staleClaimMs: 1_800_000 } },
extract: { from: 'TRANSCRIBED', working: 'EXTRACTING', policy: { delaysMs: [30_000, 120_000], maxAttempts: 3, staleClaimMs: 300_000 } },
} as const;
export type VoicePhase = keyof typeof VOICE_PHASES;
// voice-claims.ts (geschlossene Map VOICE_CLAIM_TABLES = { VoiceCapture: {...} }; Teil 4 ergänzt VoiceUtterance)
export interface VoiceClaim { id: string; claimToken: string; attempts: number; ownerUserId: string; colonyId: string; audioMime: string | null; durationSec: number | null }
export function claimVoiceCaptures(db: PrismaService, phase: VoicePhase, opts: { limit: number; maxTotalSec?: number }): Promise<VoiceClaim[]>; // UPDATE … FOR UPDATE SKIP LOCKED, claimToken = gen_random_uuid(), ordered createdAt; maxTotalSec über laufende Summe (Fenster-Funktion)
export function settleVoiceSuccess(tx: DbClient, phase: VoicePhase, claim: Pick<VoiceClaim, 'id' | 'claimToken'>, set: Prisma.Sql): Promise<boolean>; // CAS status = working AND claimToken
export function settleVoiceFailure(db: DbClient, phase: VoicePhase, claim: VoiceClaim, code: VoiceErrorCode): Promise<'retry' | 'failed'>;
export function deferVoiceClaim(db: DbClient, phase: VoicePhase, claim: VoiceClaim, delayMs: number): Promise<boolean>; // ohne Versuch (Tageslimit, Schlüssel fehlt)
export function releaseStaleVoiceClaims(db: PrismaService, phase: VoicePhase): Promise<{ id: string; status: string }[]>;
```
- [ ] **Schritt 1: Failing Tests**: `retryDelayMs keeps its old values`; `retryDelayMsFor uses the policy's delays`; e2e `fetch claims disjoint sets under SKIP LOCKED` (zwei parallele Claims); `respects the total seconds of audio per run`; `stale TRANSCRIBING claims are released with attempts + 1`; `the third failure ends FAILED with the code`; `settle does nothing when the claim token no longer matches`; `settle does nothing on a capture confirmed meanwhile`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS (`npx jest src/common src/voice` und die e2e-Datei). Kein Commit.
### Task 11: Job `voice-transcribe` (Phasen `fetch` und `ingest`) und Aufbewahrung im Takt
**Files**
- Create: `src/jobs/voice-transcribe.job.ts` (+ Spec), `src/voice/transcribe/work-dir.ts` (+ Spec), `src/voice/voice-retention.ts` (+ Spec)
- Modify: `src/jobs/job-names.ts` (+ Spec: `'voice-transcribe'`, Label „Sprachnotizen abschreiben“, `JOB_FAILURE_PERMISSION: 'system:manage'`, `JOB_ALLOWED_ARGS: ['--phase=fetch', '--phase=ingest']`), `src/jobs/jobs.module.ts`, `src/common/worker-wakeup.ts` (`voiceExtraction: 'voice_extraction'` – nur die Konstante; die Schleife folgt in Task 15)
- Test: `voice-transcribe.job.spec.ts`, `work-dir.spec.ts`, `voice-retention.spec.ts`, Erweiterung `test/jobs.e2e-spec.ts`
**Interfaces**
```ts
// work-dir.ts (Pfad aus VOICE_WORK_DIR, Standard /work; Dateinamen nur aus cuids)
export interface WorkManifest { v: 1; items: { captureId: string; claimToken: string; file: string; mime: VoiceAudioMime; reportedSec: number | null }[] }
export interface WhisperResult { v: 1; captureId: string; text: string; durationSec: number; language: string; languageProbability: number; segments: TranscriptSegment[]; error?: 'NO_SPEECH' | 'TOO_LONG' | 'DECODE_FAILED' }
export function writeInputs(dir: string, items: { claim: VoiceClaim; audio: Buffer }[]): Promise<WorkManifest>; // 0600, Größe ≤ 32Mi geprüft vor dem Schreiben
export function readResults(dir: string, manifest: WorkManifest): Promise<Map<string, WhisperResult | null>>; // null = keine/kaputte Datei; Schema streng geprüft
// voice-retention.ts
export interface VoiceRetentionCounts { audioExpired: number; contentExpired: number; inactiveOwnersWiped: number }
export function expireVoiceContent(db: PrismaService): Promise<VoiceRetentionCounts>; // Ton > 24 h -> FAILED/AUDIO_EXPIRED (+ VoiceCaptureFailed); contentExpiresAt < now() -> EXPIRED; Inhalte deaktivierter Nutzer löschen
export function deleteOldVoiceRows(db: PrismaService): Promise<number>; // createdAt < now() - 90 Tage
// voice-transcribe.job.ts
run(['--phase=fetch']): expireVoiceContent; releaseStaleVoiceClaims('transcribe'); claim ≤ 5 / ≤ 600 s; Ton entschlüsseln; writeInputs; JobResult { claimed, audioExpired, contentExpired }
run(['--phase=ingest']): Manifest lesen (fehlt -> { ingested: 0 }); je Ergebnis: Erfolg -> sealTranscript, Audio-Zeile löschen, status TRANSCRIBED, attempts 0, durationSec gemessen, pg_notify('voice_extraction') in einer Transaktion (CAS);
NO_SPEECH/TOO_LONG -> FAILED mit Code, Ton löschen, VoiceCaptureFailed; fehlend/DECODE_FAILED -> settleVoiceFailure(TRANSCRIBE_FAILED); Arbeitsdateien danach löschen; JobResult { ingested, failed }
```
- Ohne offene Arbeit: `fetch` macht eine Abfrage (`SELECT 1 … LIMIT 1`) und endet; Gesamtlauf < 5 s (Spec 4.2).
- [ ] **Schritt 1: Failing Tests**: `fetch writes one file per claimed capture and a manifest`; `fetch ends quickly without work and writes no manifest`; `ingest stores TRANSCRIBED, deletes the audio and wakes the worker in one transaction`; `ingest does nothing when the claim token no longer matches`; `a confirmed capture is not overwritten by a late ingest`; `NO_SPEECH and TOO_LONG end FAILED and delete the audio`; `a missing result counts as a failed attempt`; `expires audio after 24 h within one CronJob run`; `wipes content after 7 days and marks EXPIRED`; `wipes content of deactivated users`; `job names accept only --phase=fetch and --phase=ingest`; `ingest never logs the transcript`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 12: Fachwortliste für Whisper exportieren (Brücke Node → Python)
**Files**
- Create: `ApiCore-Backend/scripts/export-voice-vocabulary.ts` (schreibt `ApiCore-Whisper/vocabulary.txt` aus `voice-vocabulary.ts`), Wächter `src/voice/core/voice-vocabulary-export.spec.ts`
**Interfaces**
- `ApiCore-Whisper/vocabulary.txt` ist eingecheckt; der Wächter vergleicht ihn mit `whisperInitialPrompt()` (eine Quelle, Spec 6.3).
- [ ] **Schritt 1: Failing Test** `the whisper vocabulary file equals whisperInitialPrompt()`. **Schritt 2–4:** FAIL → Skript, Datei → PASS. Kein Commit.
### Task 13: Image `apicore-whisper`, CronJob-Komponente `voice`, Policy-Tests
**Files**
- Create: `ApiCore-Whisper/transcribe.py`, `ApiCore-Whisper/requirements.txt` (mit `--hash`), `ApiCore-Whisper/Dockerfile`, `ApiCore-Whisper/tests/test_transcribe.py`, `ApiCore-Whisper/vocabulary.txt` (aus Task 12), `.gitea/workflows/whisper-image.yml`
- Create: `deploy/k8s/components/voice/kustomization.yaml`, `cronjob-voice-transcribe.yaml`, `patch-worker-voice.yaml`
- Modify: `deploy/k8s/overlays/encry/kustomization.yaml` (Komponente `voice` als letzte, Image `git.encry.de/nick.rosenloecher/apicore-whisper` mit Platzhalter-Digest), `deploy/k8s/overlays/encry/configmap-backend.yaml` (`VOICE_RECORDING_ENABLED: "false"`, `VOICE_MODEL: claude-sonnet-5-5`, `VOICE_DAILY_EXTRACTIONS: "60"`), `deploy/k8s/tests/manifest-policy.test.mjs`, `deploy/k8s/tests/render-stages.mjs` (Stufe `voice`)
**Interfaces**
- `transcribe.py`: liest `/work/in/manifest.json`; ohne Manifest Exit 0 sofort; lädt `WhisperModel('/models/small', device='cpu', compute_type='int8', cpu_threads=int(os.environ['WHISPER_CPU_THREADS']))` einmal; je Datei nacheinander, eine kaputte Datei bricht den Lauf nicht ab; schreibt `/work/out/<captureId>.json` nach `WhisperResult`; Dauer vor dem Abschreiben per PyAV messen (`TOO_LONG` > 240 s ohne Dekodieren des Rests); vor dem Modell-Laden `socket.socket` gesperrt (Belt-and-braces zur NetworkPolicy). Ausgabe auf stdout nur Zähler.
- Dockerfile: `python:3.12-slim@sha256:…`, Nutzer 1000, `pip install --require-hashes -r requirements.txt`, Modelldateien `small` int8 nach `/models/small` mit SHA-256-Prüfung im Build, kein Netz zur Laufzeit, `pytest` als Build-Stufe. CI: nur bei Änderungen unter `ApiCore-Whisper/`, Tag `sha-<commit>`, `trivy` wenn im Runner vorhanden.
- CronJob (vollständig, Widerspruch 10): Objekt-Label `app: apicore-voice-job`, Pod-Labels `app: apicore-job`, `apicore.io/job: voice-transcribe`, `db-client: "true"`; Werte aus Global Constraints; `restartPolicy: Never`, `backoffLimit: 0`, `ttlSecondsAfterFinished: 86400`, `automountServiceAccountToken: false`, Pod-`securityContext` wie die anderen Jobs; Volumes `work` (`emptyDir: { medium: Memory, sizeLimit: 32Mi }`) und `tmp`; `initContainers: [fetch, whisper]`, `containers: [ingest]`; `fetch`/`ingest`: Backend-Image, `node dist/cli.js voice-transcribe --phase=…`, `APICORE_ROLE=job`, `envFrom` ConfigMap + `apicore-backend-secret`; `whisper`: Image `apicore-whisper`, **kein** `env`/`envFrom` außer `WHISPER_CPU_THREADS`, nur Volume `work` (ganzer Mount auf `/work`, Schreibrecht nötig für `/work/out`), `readOnlyRootFilesystem`, `runAsNonRoot` uid 1000, `capabilities.drop: [ALL]`, `seccompProfile: RuntimeDefault`.
- `patch-worker-voice.yaml`: Worker bekommt `ANTHROPIC_API_KEY` per `secretKeyRef` (`apicore-anthropic`, `optional: true`); kein anderer Pod.
- `manifest-policy.test.mjs` neu: `whisper container has no env from secrets, no secretKeyRef and no service account token`; `the voice pod matches the jobs network policy (egress only postgres and dns)`; `work volume is a memory emptyDir of at most 32Mi`; `resources match spec 11.1`; `the voice CronJob is not matched by the shared job patch and has exactly fetch, whisper, ingest`; `ANTHROPIC_API_KEY appears only in the worker`; `apicore-whisper is pinned by digest in the overlay`.
- [ ] **Schritt 1: Failing Tests**: pytest `writes an empty result for silence` (`NO_SPEECH`), `rejects audio longer than 240 seconds`, `rejects a corrupt file without crashing the batch`, `processes files one after the other and writes one result each`, `does not read the network` (Socket-Sperre), `writes segments with start, end and text`; die Policy-Tests oben.
- [ ] **Schritt 2:** FAIL. **Schritt 3: Implementieren.** **Schritt 4:** `docker build` lokal (falls Docker fehlt: CI-Lauf im Bericht vermerken), Policy-Tests und `render-stages.mjs` grün. Orchestrator committet `feat: in-cluster voice transcription job and whisper image (T7 L3)`.
---
## Lieferung L4 – Auswertung mit Claude
### Task 14: Port, Anthropic-Adapter, Prompt, Schema, Prüfung
**Files**
- Modify: `ApiCore-Backend/package.json` (`@anthropic-ai/sdk` exakt; vorher `supply-chain-risk-auditor`), `src/common/no-network-outside-loops.spec.ts` (Ausnahme Widerspruch 17)
- Create: `src/voice/extraction/structured-model.port.ts`, `anthropic-structured-model.ts`, `voice-prompt.ts`, `voice-schema.ts`, `validate-suggestions.ts`, `fake-structured-model.ts` (Test-Hilfe) (+ Specs)
- Test: `voice-suggestions.spec.ts`, `voice-schema.spec.ts`, `voice-prompt.spec.ts`, `anthropic-structured-model.spec.ts` (SDK gemockt)
**Interfaces**
```ts
export const STRUCTURED_MODEL = Symbol('STRUCTURED_MODEL');
export interface SystemBlock { text: string; cache?: '5m' | '1h' }
export interface TokenUsage { input: number; output: number; cacheRead: number; cacheWrite: number }
export interface StructuredRequest { model: string; system: readonly SystemBlock[]; userText: string; schema: object; maxTokens: number; timeoutMs: number; maxRetries: number; effort: 'low' }
export type StructuredOutcome =
| { kind: 'ok'; data: unknown; usage: TokenUsage; model: string }
| { kind: 'refused' | 'truncated'; usage: TokenUsage; model: string }
| { kind: 'error'; code: 'AUTH' | 'RETRYABLE' | 'BAD_REQUEST' };
export interface StructuredModel { readonly configured: boolean; extract(request: StructuredRequest, signal?: AbortSignal): Promise<StructuredOutcome> }
// anthropic-structured-model.ts: client.messages.create({ model, max_tokens, system: blocks.map(b => ({ type: 'text', text: b.text, ...(b.cache ? { cache_control: { type: 'ephemeral', ttl: b.cache } } : {}) })),
// messages: [{ role: 'user', content: userText }], output_config: { effort, format: { type: 'json_schema', schema } } }, { timeout, maxRetries, signal });
// stop_reason 'refusal' -> refused, 'max_tokens' -> truncated, JSON-Parse-Fehler -> ok mit data = undefined (Prüfung verwirft); 401/403 -> AUTH; 429/5xx/Timeout/Netz -> RETRYABLE; 400 -> BAD_REQUEST.
// baseURL nur aus der Umgebung (Standard des SDK), nie aus Daten; Logger des SDK aus.
// voice-prompt.ts
export const VOICE_PROMPT_VERSION = 1;
export function buildSystemBlocks(): SystemBlock[]; // Regeln 1–9 aus Spec 6.4, Fachwortliste, Skalen aus INSPECTION_SCALES, feedStatus-Anker aus FEED_CONDITION_LABELS_DE; fest, ohne Datum/Volk/Nutzer
export function buildUserText(transcript: string): string; // '<transkript>' + Text + '</transkript>', Text mit '<'/'>' entschärft
// voice-schema.ts
export const SUGGESTION_SCHEMA: object; // Spec 6.5, field-enum = INSPECTION_VOICE_FIELDS, kein minimum/maximum
// validate-suggestions.ts
export interface ValidatedSuggestions { suggestions: VoiceSuggestionView[]; unclear: string[]; dropped: number; truncated: number }
export function validateSuggestions(raw: unknown, transcript: string): ValidatedSuggestions | null; // null = Form passt nicht (EXTRACT_INVALID)
```
- [ ] **Schritt 1: Failing Tests**: `drops a finding whose quote is not in the transcript`; `drops a value outside the field's range`; `drops an unknown field`; `keeps one suggestion per field and notes the earlier value`; `matches the quote across punctuation and case`; `shortens long free text and marks it`; `never returns weather or temperature`; `returns null for a response that is not the schema shape`; `caps unclear at five and findings at twenty`; `schema field enum equals INSPECTION_VOICE_FIELDS`; `the system prompt contains no date, colony or user` (Snapshot mit festen Platzhaltern); `buildUserText neutralises angle brackets in the transcript`; Adapter: `sends output_config with effort low and the json schema`, `sends no tool_choice and no sampling parameters`, `maps refusal, max_tokens, 401 and 529`, `marks a system block for cache only when asked`; Netzwerk-Wächter: `@anthropic-ai/sdk is imported only by anthropic-structured-model.ts`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Bau-Notiz: aktuelle Anthropic-Doku zu `output_config`, Modellkennungen und `fallbacks` gegenlesen (claude-api-Skill); Abweichungen im Bericht, Code passt sich an, Spec bleibt Quelle der Absicht. Kein Commit.
### Task 15: Worker-Schleife `voice-extractions`
**Files**
- Create: `src/voice/extraction/voice-extraction.service.ts` (+ Spec), `src/voice/voice-worker.module.ts` (stellt `STRUCTURED_MODEL` → Anthropic-Adapter, Handler-Modul, Service bereit)
- Modify: `src/worker/worker-runtime.service.ts` (+ Spec: Schleife), `src/worker/worker.module.ts` (importiert `VoiceWorkerModule`), `src/runtime/startup-checks.ts` (ruft `assertAnthropicKey`) – Hinweis: `startup-checks.ts` gehört in W2 Task 5; hier nur die eine Zeile, Welle 5 ist frei
- Test: `voice-extraction.service.spec.ts`, `worker-runtime.service.spec.ts`, Erweiterung `test/domain-event-registry`-Spec (Handler im Worker registriert)
**Interfaces**
- Schleife: `{ name: 'voice-extractions', intervalMs: 15_000, channel: WAKE_CHANNELS.voiceExtraction, tick: () => this.voiceExtraction.runOnce(this.stopping.signal) }`.
- `VoiceExtractionService.runOnce(signal): Promise<void>` = `releaseStaleVoiceClaims('extract')` + `drain(signal)`; `drain` claimt je Runde 1, nacheinander, bis nichts mehr da oder `signal` abgebrochen.
- Je Notiz: Schlüssel fehlt (`configured === false`) → beim Start einmal „voice extraction disabled“, Notizen bleiben `TRANSCRIBED` (`deferVoiceClaim` 1 h), `lastErrorCode EXTRACT_DISABLED`, Meldung an `system:manage` einmal; Tageslimit (`count(*)` heutiger UTC-Tag mit `model IS NOT NULL`) ≥ `dailyExtractions` → `deferVoiceClaim` bis Mitternacht UTC, `DAILY_LIMIT`; sonst `openTranscript` → `extract` (Modell aus `voiceConfig`, `max_tokens` 2000, 60 s, 2 Wiederholungen) → `ok` + `validateSuggestions` ≠ null → in **einer** Transaktion `suggestionEnc = sealVoiceText(JSON, 'capture:<id>:suggestion')`, `model`, Tokens, `READY`, `readyAt`, Ereignis `VoiceCaptureReady` (CAS); `refused` → `FAILED/REFUSED` + `VoiceCaptureFailed`; `truncated`/null → `settleVoiceFailure(EXTRACT_INVALID)`; `RETRYABLE` → `settleVoiceFailure(EXTRACT_RETRY)`, beim 3. Mal `EXTRACT_FAILED`; `AUTH` → wie Schlüssel fehlt.
- [ ] **Schritt 1: Failing Tests**: `stores READY and writes VoiceCaptureReady in one transaction`; `keeps TRANSCRIBED on 401 and notifies system:manage once`; `retries with the backoff and ends FAILED after three`; `handles refusal`; `respects the daily limit`; `a confirmed capture is not overwritten by a late extraction`; `never logs the transcript` (Logger-Spion, auch im Fehlerpfad mit SDK-Fehler, der den Text in der Message hätte); `the worker registers voice-extractions with the voice_extraction channel`; `voice handlers are registered in the worker`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 16: Aufräumen erweitert, Gold-Menge, Kostenanzeige
**Files**
- Modify: `src/jobs/cleanup.job.ts` (+ Spec): ruft `expireVoiceContent`, `deleteOldVoiceRows`, löscht leere `voice-transcribe`-JobRuns > 24 h (Widerspruch 16), Zähler im `JobResult` (`voiceAudioExpired`, `voiceContentExpired`, `voiceRowsDeleted`, `voiceIdleJobRuns`)
- Create: `ApiCore-Backend/scripts/eval-voice.ts`, `test/fixtures/voice/gold/*.json` (30 erfundene, anonyme Fälle: Transkript + erwartete Vorschläge), `package.json` Skript `eval:voice`
- Test: `voice-cleanup.spec.ts`
**Interfaces**
- `eval:voice` ruft den echten Adapter nur mit `ANTHROPIC_API_KEY` und `--yes`, misst je Modell: Genauigkeit Wahrheitswerte (Ziel ≥ 98 %), Trefferquote (≥ 85 %), Anzahl nicht gesagter Übernahmen (Ziel 0), Tokens und Kosten; nie in der CI (Wächter: kein Workflow ruft `eval:voice`).
- [ ] **Schritt 1: Failing Tests**: `deletes audio after 24 h`; `wipes content after 7 days and marks EXPIRED`; `deletes rows after 90 days`; `leaves open captures`; `deletes idle voice-transcribe job runs after a day and keeps runs with work or errors`; `no workflow runs eval:voice`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Orchestrator prüft L3+L4-Gates und committet `feat: voice extraction with Claude structured output (T7 L4)`.
---
## Lieferung L5 – Aufnahme, Warteschlange, Prüfen (Frontend)
### Task 17: Recorder und Speicher `pending-voice`
**Files**
- Create: `ApiCore-Frontend/src/lib/voice/recorder.ts`, `pending-voice.ts` (+ Specs)
- Modify: `ApiCore-Frontend/src/lib/offline/db.ts` (+ `db.spec.ts`; Version = nächste freie, Widerspruch 11; Store `pending-voice` mit Index `ownerUserId`)
**Interfaces**
```ts
export interface Recording { blob: Blob; mime: string; durationSec: number }
export interface RecordingHandle { stop(): Promise<Recording>; cancel(): void }
export function pickMimeType(win?: Window): string | null; // 'audio/webm;codecs=opus' | 'audio/mp4' | null
export function startRecording(opts: { maxSec: number; warnAtSec: number; onTick(sec: number): void; onWarn(): void; onAutoStop(r: Recording): void }): Promise<RecordingHandle>;
// getUserMedia({ audio: { channelCount: 1 } }) erst hier (Tipp), audioBitsPerSecond 24000, wakeLock wo vorhanden, visibilitychange -> stop und speichern, track.stop() bei stop/cancel
export interface StoredVoiceNote { id: string; ownerUserId: string; blob: Blob; mime: string; durationSec: number; colonyId: string; inspectionDate: string; createdAt: string; uploadIdempotencyKey: string; state: 'recorded' | 'uploading'; lastError?: QueueError }
export function queueVoiceNote(input: Omit<StoredVoiceNote, 'id' | 'createdAt' | 'uploadIdempotencyKey' | 'state'>): Promise<StoredVoiceNote>; // fragt beim ersten Mal navigator.storage.persist(); QuotaExceeded -> wirft VoiceStorageFullError
export function listPendingVoice(currentUserId: string): Promise<StoredVoiceNote[]>;
export function countForeignVoice(currentUserId: string): Promise<number>;
export function discardPendingVoice(id: string): Promise<void>;
export function discardAllPendingVoice(ownerUserId: string): Promise<number>;
```
- [ ] **Schritt 1: Failing Tests**: `upgrading to the new version keeps queued inspections, photos and phenology reports`; `stops at 180 seconds and keeps the recording`; `warns at 150 seconds`; `requests the microphone only on start and releases it on stop`; `saves what was recorded when the page goes to the background`; `rejects a new note when storage is full and keeps the stored ones`; `never lists another user's notes`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 18: Hochladen in der Synchronisierung, Warteschlangen-Sheet, Abmelden
**Files**
- Create: `ApiCore-Frontend/src/lib/voice/api.ts`, `upload-pending-voice.ts` (+ Specs)
- Modify: `src/lib/offline/sync.ts` (+ `sync.spec.ts`: Schritt „Sprachnotizen“ nach den Datensätzen und Fotos, innerhalb des Laufs, `isCancelled` beachtet), `src/lib/offline/queue-texts.ts` (+ Spec), `src/components/offline/pending-queue-sheet.tsx` (+ Spec: Abschnitt „Sprachnotizen“ mit „Erneut aufnehmen“ und „Verwerfen“, fremde nur als Anzahl), `src/components/offline/offline-band.tsx` (+ Spec: zählt Sprachnotizen mit), die Abmelde-Rückfrage (T3 Task 16, gleicher Weg) um wartende Aufnahmen
**Interfaces**
```ts
export const voiceKeys = { all: ['voice-captures'] as const, detail: (id: string) => ['voice-captures', id] as const, config: ['voice-captures', 'config'] as const };
export function uploadVoiceCapture(note: StoredVoiceNote): Promise<{ id: string; status: 'QUEUED' }>; // FormData audio + colonyId + inspectionDate + durationSec, Header Idempotency-Key = uploadIdempotencyKey
export function submitVoiceText(input: { colonyId: string; inspectionDate: string; text: string }, idempotencyKey: string): Promise<{ id: string }>;
export function fetchVoiceCaptures(status?: string): Promise<VoiceCaptureListItem[]>; export function fetchVoiceCapture(id: string): Promise<VoiceCaptureDetail>;
export function fetchVoiceConfig(): Promise<VoiceClientConfig>; export function retryVoiceCapture(id: string): Promise<void>;
export function discardVoiceCapture(id: string): Promise<void>; export function discardAllVoiceCaptures(): Promise<{ discarded: number }>;
export function uploadPendingVoice(userId: string, isCancelled: () => boolean): Promise<SyncResult>; // 2xx -> Eintrag sofort löschen; 4xx außer 401/408/409/429 -> lastError; Netz/5xx -> stoppen
```
- [ ] **Schritt 1: Failing Tests**: `uploads recorded voice notes after records`; `deletes the local audio after a 2xx`; `keeps 4xx rejections as lastError`; `stops on a network error and keeps the rest`; `never sends another user's recordings`; `a cancelled run sends no voice note`; Sheet: `shows a voice note with Erneut aufnehmen and Verwerfen`, `shows only the count of another account's notes`; Band: `counts voice notes in the waiting entries`; Abmelden: `asks to discard waiting recordings`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 19: Aufnahmeseite, Einstiege, Liste `/voice`
**Files**
- Create: `ApiCore-Frontend/src/app/(app)/voice/page.tsx`, `src/app/(app)/voice/new/page.tsx`, `src/components/voice/voice-recorder.tsx`, `recording-notice-dialog.tsx`, `voice-capture-card.tsx`, `src/lib/voice/status-texts.ts` (+ Specs)
- Modify: `src/app/(app)/quick-actions.ts` + `quick-add-sheet.tsx` (Eintrag „Sprachnotiz“, Mikrofon-Symbol, nur mit `voice-captures:use` und `recordingEnabled`), `src/components/rounds/round-next-panel.tsx` bzw. Standrunde (Knopf „Sprachnotiz aufnehmen“ neben „Durchsicht erfassen“), Volksseite (`colonies/[id]/page.tsx`, Knopf neben „Durchsicht“), `src/app/(app)/nav-config.ts` (+ Spec: „Sprachnotizen“ unter „Mehr“)
- Test: `voice-recorder.spec.tsx`, `voice/page.spec.tsx`, `voice/new/page.spec.tsx`, `quick-add-sheet.spec.tsx`, `nav-config.spec.ts`
**Interfaces**
- `/voice/new?colonyId=&date=&apiaryId=`: Kopf „B3 · 14.10.“ (änderbar über `ColonySelectField`/Datum), Satz „Sprich in ganzen Sätzen, nur du.“, Gedächtnisstütze (Königin · Stifte · Brut · Waben · Sanftmut · Futter · Honigraum · Varroa · Maßnahmen), Aufnahmetaste 72 px mit Text „Aufnahme starten“ → „Aufnahme beenden“ am selben Ort; während der Aufnahme rote Markierung **und** „Aufnahme läuft“ plus Zeit; Ansage nur alle 30 s und beim Stopp (`aria-live="polite"`); kein Pulsieren bei `prefers-reduced-motion`; nach Stopp native Wiedergabe, Hauptaktion „Senden“ (offline „Speichern“), „Neu aufnehmen“, „Verwerfen“; Erfolgstexte exakt Spec 8.1; „Weiter mit B4“ in der Standrunde; Mikrofon verweigert: Schritt-für-Schritt je Browser und „Stattdessen tippen“; erster Start: `RecordingNoticeDialog` mit Spec-9.5-Text.
- `/voice`: Karten (Volk, Zeit, Status mit Symbol **und** Wort aus `VOICE_STATUS_TEXTS_DE`: „Wartet auf Netz“, „Wird abgeschrieben“, „Wird ausgewertet“, „Fertig – prüfen“, „Fehlgeschlagen“), Hauptaktion „Prüfen“ → `/inspections/new?colonyId=…&date=…&voice=<id>`, unter „Mehr“ „Verwerfen“ (Rückfrage), „Nochmal versuchen“ bei `FAILED` mit Inhalt; wartende Geräte-Einträge oben; Leerzustand „Noch keine Sprachnotizen.“
- [ ] **Schritt 1: Failing Tests**: `shows words and not just color while recording`; `announces the time only every 30 seconds`; `shows the denied-state with a way to type instead`; `offline the main action reads Speichern and the note lands in the queue`; `shows the notice once before the first recording`; `the list shows status words and Prüfen only when ready`; `the quick-add entry is hidden without permission or when recording is disabled`; `axe finds nothing` (Aufnahme und Liste).
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 20: Vorschläge in der Durchsicht, Übernehmen, Speichern mit `voiceCaptureId`
**Files**
- Create: `ApiCore-Frontend/src/components/voice/voice-suggestion.tsx`, `voice-capture-box.tsx`, `src/lib/voice/suggestions.ts`, `src/lib/inspections/scales.ts` (+ Specs)
- Modify: `src/app/(app)/inspections/new/page.tsx` (+ `page.spec.tsx`), `inspection-form-values.ts` (+ Spec: Markierung „aus Sprache“ je Feld, `voiceCaptureId` im Input, nicht im Entwurf), `src/lib/inspections/types.ts` (`CreateInspectionInput.voiceCaptureId?: string`), `src/lib/offline/sync.ts` **nicht** (409-Weg siehe unten über `useCaptureMutation`)
- Test: `voice-suggestion.spec.tsx`, `voice-capture-box.spec.tsx`, `suggestions.spec.ts`, `scales.spec.ts`, `page.spec.tsx`
**Interfaces**
```ts
// suggestions.ts
export function suggestionPatch(s: VoiceSuggestionView): Partial<InspectionFormValues>; // feedStatus -> ColonyCondition, Skalen gespeichert, Freitext angehängt an leere/vorhandene Felder nach Formular-Regel
export function describeSuggestionValue(s: VoiceSuggestionView): string; // „Königin gesehen: Ja“, „Futter: Niedrig“, „Sanftmut: 4 von 5“
export function openSuggestions(all: VoiceSuggestionView[], values: InspectionFormValues, taken: ReadonlySet<string>): VoiceSuggestionView[];
// scales.ts
export const INSPECTION_SCALE_DIRECTIONS: Record<'broodPattern' | 'swarmMood' | 'gentleness' | 'combBehavior', RatingDirection>; // Formular nutzt sie; Wächter gegen test/fixtures/voice/inspection-scales.json
```
- `<VoiceSuggestion fieldLabel valueText quote chips onTakeOver onEdit />` (generisch, Teil 4): Wert in Worten, Zitat in Anführungszeichen, „Übernehmen“, „Ändern“, Chips „Korrigiert“/„Zuvor genannt: …“; Enter auf „Übernehmen“ setzt den Wert, meldet „Übernommen“ in einem `role="status"`-Bereich, Fokus bleibt; „Ändern“ setzt und fokussiert das Feld.
- `<VoiceCaptureBox capture onTakeOverAll onDiscard />`: „Sprachnotiz vom 14.10., 12 Vorschläge“, „Alle übernehmen“ mit Rückfrage „12 Felder aus der Sprachnotiz übernehmen?“, „Sprachnotiz verwerfen“ (Rückfrage), aufklappbar „Gesagt wurde:“ mit vollem Text (kopierbar), `unclear` als „Das konnte ich nicht zuordnen“ ohne Knopf, `dropped > 0` und leer: „Nichts Eindeutiges erkannt. Der Text steht oben.“; Status ≠ READY: passende Zeile („Wird noch ausgewertet“, „Auswertung gerade nicht möglich“ + Text).
- Seite: `?voice=<id>` lädt `fetchVoiceCapture`, setzt Volk und Datum der Notiz, kein Feld belegt; Hinweise unter jedem Feld mit Vorschlag, **unter** dem „Letztes Mal“-Hinweis mit klarer Beschriftung; übernommene Felder bleiben „aus Sprache“, bis bearbeitet oder gespeichert; Speichern schickt `voiceCaptureId`; offline gespeichert geht die Id mit in die Warteschlange; Antwort 409 `VOICE_CAPTURE_GONE` (online oder beim Sync) → genau ein erneuter Versuch ohne `voiceCaptureId` mit denselben Eingaben und neuem Idempotency-Key, Hinweis „Sprachnotiz war abgelaufen, Durchsicht ohne sie gespeichert“; 409 `VOICE_CAPTURE_COLONY_MISMATCH` → Fehlertext, nichts gespeichert, Angebot „Ohne Sprachnotiz speichern“. Nach Erfolg `voiceKeys.all` invalidieren.
- Sync-Seite des 409-Wegs: `drain` für `pending-inspections` bekommt einen Hook `retryWithout(entry, error)` (kleine Erweiterung in `sync.ts`) – **Dateikonflikt mit Task 18**: läuft in W7 nach W6, also zulässig; Test `a queued inspection with an expired voice capture is saved without it`.
- [ ] **Schritt 1: Failing Tests**: `leaves the field empty until Übernehmen`; `Übernehmen sets the value and moves no focus`; `Ändern focuses the field`; `Alle übernehmen asks first and names the count`; `works with the keyboard only`; `axe finds nothing`; `saves with voiceCaptureId and shows no suggestion afterwards`; `a 409 VOICE_CAPTURE_GONE retries without the id`; `a queued inspection with an expired voice capture is saved without it`; `both hints stand one below the other and neither fills the field`; `scale directions equal the shared fixture`; `suggestion text is rendered as text, never as HTML`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 21: Text-Weg „Ganze Durchsicht auswerten“
**Files**
- Create: `ApiCore-Frontend/src/components/voice/evaluate-dictation-button.tsx` (+ Spec)
- Modify: `src/app/(app)/inspections/new/page.tsx` (unter den Freitextfeldern; Zustand „mindestens ein Diktat“ aus `useDictation` gemeldet), nach Task 20 in derselben Welle
**Interfaces**
- Sichtbar nur online und wenn mindestens ein Diktat vorliegt; schickt die diktierten Texte zusammen (≥ 20, ≤ 4000 Zeichen, sonst Hinweis) an `submitVoiceText`, zeigt danach den Zustand der Notiz in `VoiceCaptureBox` (Abfrage alle 5 s bis `READY`/`FAILED`, höchstens 2 min, dann Hinweis auf die Meldung) und Vorschläge **nur für leere Felder** (Spec 8.3).
- [ ] **Schritt 1: Failing Tests**: `is hidden offline and without dictation`; `sends the dictated text once with an idempotency key`; `offers suggestions only for empty fields`; `stops polling after two minutes and points to the notification`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS. Kein Commit.
### Task 22: Info-Seite vervollständigen, System-Zähler
**Files**
- Modify: `ApiCore-Frontend/src/app/(app)/settings/voice/page.tsx` (+ Spec: Modellkennung aus `fetchVoiceConfig`, „Alle meine Sprachnotizen löschen“ → `discardAllVoiceCaptures` + `discardAllPendingVoice`, Rückfrage mit Anzahl), `src/app/(app)/settings/system/…` (+ Spec: Karte „Sprachnotizen“ mit Zählern je Status/Fehlercode und Monatskosten aus Tokens × Preis aus einer Tabelle `VOICE_MODEL_PRICES_USD` in `lib/voice/prices.ts`, nur mit `system:manage`), `src/lib/system/labels.ts` (+ Spec: `DOMAIN_EVENT_LABELS` für `VoiceCaptureReady` „Sprachnotiz fertig“, `VoiceCaptureFailed` „Sprachnotiz fehlgeschlagen“; Job-Label), `public/sw-routes.js` + `public/sw.js` (`SW_ROUTES_VERSION`) + `src/lib/offline/sw-routes.spec.ts` (`/api/v1/voice-captures` nie gecacht)
- [ ] **Schritt 1: Failing Tests**: `shows the model id from the server`; `delete all asks with the count and empties server and device`; `the system card shows counts without user names`; `voice-captures paths are never cached by the service worker`; `labels exist for both voice events`.
- [ ] **Schritt 2–4:** FAIL → Implementieren → PASS.
- [ ] **Schritt 5: Browser-Prüfung** (chrome-devtools-MCP, lokales Backend mit `FakeStructuredModel` über `VOICE_FAKE_MODEL=1` nur außerhalb Produktion, Ersatz-Whisper per Testdatei): 390 px und 1366 px, Flugmodus-Aufnahme → Netz an → Meldung → Prüfen → Speichern; Tastatur und Screenreader-Namen der Prüfansicht; `prefers-reduced-motion`; Leer- und Fehlerzustände. Orchestrator committet `feat: voice notes recording, queue and suggestion review (T7 L5)`.
---
## Lieferung L6 – Ende-zu-Ende, Messung, Doku
### Task 23: e2e gegen die Test-DB
**Files**
- Create: `ApiCore-Backend/test/voice-captures.e2e-spec.ts`
- Modify: `test/authorization-coverage.e2e-spec.ts` (läuft grün mit den neuen Routen)
- [ ] **Schritt 1: e2e schreiben** (Spec 12): Upload → `QUEUED`; `fetch`-Phase direkt aufgerufen → Datei im Arbeitsverzeichnis (Temp-Verzeichnis); Ersatz-Whisper schreibt `WhisperResult` → `ingest` → `TRANSCRIBED`, Audio-Zeile weg; Schleife mit `FakeStructuredModel` → `READY` und genau eine Meldung nach `drainDomainEvents`; `POST /inspections` mit `voiceCaptureId` → `CONFIRMED`, `transcriptEnc` und `suggestionEnc` `null`, Audio weg; fremder Nutzer 404; READONLY und API_CLIENT 403; zwei parallele `fetch`-Läufe → jede Aufnahme genau einmal; Text-Weg → `TRANSCRIBED` → `READY`; Log-Mitschnitt über den ganzen Lauf enthält den Text nicht.
- [ ] **Schritt 2:** `npx jest --config ./test/jest-e2e.json test/voice-captures.e2e-spec.ts --runInBand` → PASS (Code reparieren, nicht Tests). Danach alle e2e mit `--runInBand`.
### Task 24: Messung vor der Freigabe (mit Freigabe des Inhabers)
- [ ] **Schritt 1:** Spec 11.4 Punkt 1–2 auf dem Zielknoten: 10 Aufnahmen des Inhabers (30–180 s, Standumgebung), Laufzeit, Spitzen-RAM, CPU-Drosselung, Fachwort-Trefferquote (Ziel ≥ 90 %); Ergebnis in `progress.md`. Darunter: Fachwortliste erweitern oder `medium` (Request 2Gi) nur nach Freigabe.
- [ ] **Schritt 2:** `npm run eval:voice -- --yes` (kostet Geld, nur nach Freigabe) mit `claude-sonnet-5-5`; nur wenn Kosten Thema werden zusätzlich `claude-haiku-5-5`; verfehlt Sonnet die Ziele → `VOICE_MODEL=claude-opus-5-5` vorschlagen. Ergebnis in `progress.md`.
### Task 25: Abschluss und Rollout (Orchestrator)
- [ ] **Schritt 1:** Alle Gates Backend, Frontend, Manifeste, Whisper-CI; `code-simplifier` auf den geänderten Code; Gesamt-Diff-Review (Sicherheitsblick: Logs, Rechte, Netz).
- [ ] **Schritt 2: Doku im selben Commit:** ARCHITECTURE nächste freie Annahmen „Sprachnotizen und Datenschutz“, „Abschreiben im Cluster“, „Auswertung mit Claude (Schema und Prüfung)“; §1 Modul `voice`; §7 Komponente `voice`, Pod-Aufbau, Secret `apicore-anthropic`; §10 Schleife `voice-extractions`, Wake-Kanal, Ereignisse; `docs/pruefung-2026-09.md` „Sprachdaten“; TASKS.md: „Sprache, KI und Sensoren nicht umsetzbar“ für Sprache und KI korrigiert, Sensoren „nicht geplant, kein Gerät“.
- [ ] **Schritt 3: Rollout** (jeder Schritt mit Freigabe, `kubectl diff -k` vorher): 0. Inhaber: Anthropic-Schlüssel, Ausgabenlimit (Vorschlag 10 $/Monat), DPA, Datenschutzerklärung; `ensure-secrets.sh` ergänzt `VOICE_ENCRYPTION_KEY` (**vor** Schritt 1, Widerspruch 8); Inhaber legt `apicore-anthropic` an. 1. Backend-Image mit Migration (`VOICE_RECORDING_ENABLED=false`, Auswertung ohne Schlüssel aus). 2. `apicore-whisper` bauen, Digest ins Overlay. 3. Komponente `voice` einschalten; erster Lauf `kubectl create job --from=cronjob/apicore-job-voice-transcribe`; Logs auf Inhalte prüfen (keine). 4. Messung Task 24. 5. `VOICE_RECORDING_ENABLED=true`, Frontend freigeben.
- [ ] **Schritt 4:** Rückweg dokumentiert: `VOICE_RECORDING_ENABLED=false`, Komponente `voice` aus; offene Notizen laufen über `expireVoiceContent` ab.
---
## Abdeckung der Spec
| Spec-Abschnitt | Tasks |
|---|---|
| 1 Messkriterien 1–5 | 1: 7, 11, 15, 18, 23 · 2: 14, 20 · 3: 8, 23 · 4: 7, 11, 13, 15 · 5: 6, 14 |
| 1 Entscheidungen | 5 (Schlüssel), 10–13 (CronJob), 14–15 (Claude, Schema, Zitat), 20 (nie still), 7 (nur Aufnehmender), 11/16 (Fristen), 4 (Backup), 1–2 (Browser-Erkennung) |
| 2.1 Bausteine | Stufe 1: 1–3 · Aufnahme/Warteschlange: 17–19 · Abschreiben: 10–13 · Auswerten: 14–15 · Text-Weg: 7, 21 · Prüf-UI: 20 · Liste: 19 · Datenschutz: 3, 5, 16, 22 |
| 3 Datenmodell, Schlüssel, Rechte, Ereignisse, Backup | 4, 5, 8, 9 |
| 4 Zustände, Jobs, Schleifen | 10, 11, 13, 15, 16 |
| 5 API, Eingaben | 7, 8 (+ `config`, `discard-all`) |
| 6.1–6.2 Gerät, Diktat | 1, 2, 17, 18 |
| 6.3 Abschreiben | 11, 12, 13 |
| 6.4–6.5 Claude, Schema, Prüfung | 14, 15 |
| 6.6 Übernehmen und Speichern | 8, 20 |
| 7 Modell, Kosten, Protokoll ohne Inhalt | 5, 15, 16, 22, 24 |
| 8 Oberfläche | 2, 3, 19, 20, 21, 22 |
| 9 Sicherheit und Datenschutz | Global Constraints; 4, 5, 7, 13, 14, 15, 16, 22 |
| 10 Fehlerfälle | Codes in 6; Verhalten in 7, 8, 11, 15, 18, 19, 20 |
| 11 Betrieb und Ressourcen | 13, 15, 24 |
| 12 Tests | in den Tasks benannt; e2e 23 |
| 14 Rollout | 25 |
## Offene Fragen an den Inhaber (zusätzlich zu Spec 15)
- **A1** Widerspruch 14: Soll das Speichern einer Durchsicht eine Sprachnotiz in jedem offenen Zustand (nicht nur „Fertig“) bestätigen und ihren Inhalt löschen? **Empfehlung:** ja.
- **A2** Widerspruch 1: `colonyStrength` und `broodFrames` bis 40 (wie das Formular) statt 30, Futter in fünf Stufen statt drei? **Empfehlung:** ja, das Formular ist die Quelle.
- **A3** Meldungskategorie der Sprachnotiz `SUGGESTION` (Telegram nur, wenn gewählt) – oder lieber `SYSTEM`? **Empfehlung:** `SUGGESTION`.
- Spec-Fragen 1–3 (Modell, Browser-Cloud, Drittland) bleiben offen; der Bau ändert sich nur bei Frage 3 (ohne Anthropic entfallen Tasks 14–16 und 21).