# 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:` bzw. `voice-failed::`; 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/.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 `, 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; 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. - ``: 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::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>; export const VOICE_EXCLUDED_INSPECTION_FIELDS: Readonly>; // 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::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 } 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; VoiceCapturesService.get(userId: string, id: string): Promise; // transcript/suggestions bei READY, TRANSCRIBED, FAILED mit Inhalt VoiceCapturesService.retry(userId: string, id: string): Promise; // nur FAILED mit Ton/Text und contentExpiresAt > now() VoiceCapturesService.discard(userId: string, id: string): Promise; // idempotent, löscht Audio-Zeile, transcriptEnc, suggestionEnc VoiceCapturesService.discardAll(userId: string): Promise<{ discarded: number }>; VoiceCapturesStatsService.stats(): Promise<{ byStatus: Record; byError: Record; tokens: { input: number; output: number }; byModel: Record }>; // 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::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; // 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:' }, 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::'`. - 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; // 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, set: Prisma.Sql): Promise; // 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; // 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; // 0600, Größe ≤ 32Mi geprüft vor dem Schreiben export function readResults(dir: string, manifest: WorkManifest): Promise>; // 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; // Ton > 24 h -> FAILED/AUDIO_EXPIRED (+ VoiceCaptureFailed); contentExpiresAt < now() -> EXPIRED; Inhalte deaktivierter Nutzer löschen export function deleteOldVoiceRows(db: PrismaService): Promise; // 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/.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-`, `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 } // 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; // '' + Text + '', 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` = `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::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; 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; // 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): Promise; // fragt beim ersten Mal navigator.storage.persist(); QuotaExceeded -> wirft VoiceStorageFullError export function listPendingVoice(currentUserId: string): Promise; export function countForeignVoice(currentUserId: string): Promise; export function discardPendingVoice(id: string): Promise; export function discardAllPendingVoice(ownerUserId: string): Promise; ``` - [ ] **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; export function fetchVoiceCapture(id: string): Promise; export function fetchVoiceConfig(): Promise; export function retryVoiceCapture(id: string): Promise; export function discardVoiceCapture(id: string): Promise; export function discardAllVoiceCaptures(): Promise<{ discarded: number }>; export function uploadPendingVoice(userId: string, isCancelled: () => boolean): Promise; // 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=`, 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; // 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): 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 ``` - `` (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. - ``: „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=` 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 **demselben** Idempotency-Key (der Server gibt den Schlüssel nach dem Handler-Fehler frei und vergleicht keinen Body; ein neuer Schlüssel könnte nach verlorener Antwort eine doppelte Durchsicht erzeugen – Review 2026-10-16), 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).