Files
ApiCore/docs/superpowers/plans/2026-10-16-t7-sprache.md

83 KiB
Raw Permalink Blame History

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

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

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

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

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

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

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

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

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

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

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

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

// 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 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).