Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
83 KiB
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.
- Feldgrenzen weichen ab. Spec 6.5:
colonyStrength0–30,broodFrames0–30,feedStatusgenauausreichend|niedrig|kritisch. Code:CreateInspectionDto.colonyStrength0–40,broodFramesnurMin(0),feedStatusist EnumColonyConditionmit fünf Werten (EXCELLENT GOOD AVERAGE WEAK CRITICAL, Labels inFEED_CONDITION_LABELS_DE). →FIELD_SPECSfolgt dem DTO (Spec sagt selbst „gleiche Grenzen wieCreateInspectionDto“):colonyStrength0–40,broodFrames0–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 ausFEED_CONDITION_LABELS_DE(reichlich, ausreichend, mittel, niedrig, kritisch). Gleiche Werte wie Teil 4 §5.5 (GOOD/WEAK/CRITICAL, 0–40). - 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,hiveChangeund bekommtvoiceCaptureId. → Feste AusschlusslisteVOICE_EXCLUDED_INSPECTION_FIELDSmit Begründung je Feld; der Wächter vergleicht DTO-Eigenschaften (class-validator-Metadaten) minus Liste mit den 17 Feldern. recordForUserOncegibt es nicht.NotificationsServicehatrecord(input, tx?)mit dauerhaft eindeutigemdedupeKey. → Handler nutzenrecord(..., tx)mitdedupeKeyvoice-ready:<captureId>bzw.voice-failed:<captureId>:<code>; KategorieSUGGESTION(die Notiz ist ein Vorschlag an den Nutzer; Telegram nur, wenn der Nutzer die Kategorie gewählt hat). Text enthält nur den Volksnamen.outbox-claims.tspasst nicht aufVoiceCapture. Es kennt nurPENDING/PROCESSING, eine festeMAX_ATTEMPTS = 8, ein festesSTALE_CLAIM_MS = 300000und CAS aufclaimedAt; T7 braucht zwei Phasen mit eigenen Status, 3 Versuchen, 30 min bzw. 5 min Stale-Frist undclaimToken(der Claim wandert über zwei Prozesse).retryDelayMsist nicht parametrierbar. →retry-policy.tsbekommtRetryPolicyundretryDelayMsFor(policy, attempts)(der bestehenderetryDelayMsdelegiert, Verhalten unverändert); neuesrc/voice/claims/voice-claims.tsmit derselben SQL-Form (SKIP LOCKED,now(), geschlossene Tabellen-Map) fürVoiceCapture– Teil 4 trägtVoiceUtterancein dieselbe Map ein.- 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_INVALIDhat laut Spec 10 „einen Wiederholungsversuch“ → zählt als normaler Versuch (passt in die 3). - Ein
attempts-Zähler für zwei Phasen. →ingestsetzt beim Übergang nachTRANSCRIBEDattempts = 0;retrysetzt ihn ebenfalls auf 0 (Spec 5). - 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 nachcontentExpiresAt, Inhalte deaktivierter Nutzer) läuft zusätzlich in derfetch-Phase jedes CronJob-Laufs (alle 2 min, reine SQL, kein Schlüssel nötig);cleanupruft dieselbe Funktion und löscht Zeilen nach 90 Tagen. Teil 4 hängt dort seine 24-h-Frist für Sätze an. VOICE_ENCRYPTION_KEYnur inapi,worker,fetch/ingest(Spec 11.3) – aberstartup-checks.tsverlangt in Produktion jeden Schlüssel ausENCRYPTION_KEY_VARIABLESin jeder Rolle (API, Worker, alle Jobs), und alle Backend-Pods bindenapicore-backend-secretperenvFrom. → Schlüssel kommt wie MFA/WEBHOOK/BACKUP inapicore-backend-secret(ensure-secrets.sherzeugt ihn), und zwar vor Rollout-Schritt 1 (sonst startet das neue Image nicht). Spec 14 Schritt 3.2 wird damit Voraussetzung von 4.1.ANTHROPIC_API_KEY„in Produktion Pflicht“ und zugleich „fehlt er, startet der Worker“. → Nicht Pflicht. Eigenes Secretapicore-anthropic, nur im Worker persecretKeyRef(optional: true), Musterapicore-telegram. StartprüfungassertAnthropicKey(role, env)wieassertTelegramToken: Platzhalter oder falsches Format → Fehler-Log einmal, Auswertung aus (EXTRACT_DISABLED), kein Abbruch. API- und Job-Pods lesen den Schlüssel nie.- Gemeinsamer CronJob-Patch ist eine Falle.
components/backend-roles/kustomization.yamlpatcht alle CronJobs mit Labelapp=apicore-jobmit einem Containerjob(Strategic Merge per Name). Der Voice-CronJob hat keinen Containerjob→ der Patch fügt einen hinzu (Backend-Image ohne Kommando, alle Secrets). → Der Voice-CronJob trägt am CronJob-Objektapp: apicore-voice-job(vom Patch nicht getroffen) und nur im Pod-Templateapp: apicore-job(fürNetworkPolicy apicore-jobs); Härtung, Zeitzone,Forbidstehen vollständig im eigenen Manifest;manifest-policy.test.mjsprüft, dass er genau drei Container hat. - IndexedDB „Version 4“ ist belegt (
pending-phenology-reports, T4). Beute L5 plant v5 (pending-hive-changes), Teil 4 danachvoice-sessions. → T7 nimmt die nächste freie Version beim Bau (v5, falls Beute L5 noch nicht gemergt ist, sonst v6); der Upgrade-Zweig prüftoldVersion < N. Task 17 schreibt die tatsächliche Nummer indb.tsund in den Bericht. inspection-scales.tsgibt es nicht. Die Richtung der Skalen steckt im Formular (RatingDirectioninlib/forms/rating.ts, je Feld ininspections/new/page.tsx). → Eine gemeinsame JSON-QuelleApiCore-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-Modulinspection-scales.ts, Frontend-Wächter vergleicht sie mit einer neuen KonstanteINSPECTION_SCALE_DIRECTIONS(lib/inspections/scales.ts), die das Formular nutzt.- Rate-Limit „10/min je Nutzer“ – der Throttler zählt nach Client-IP (
ClientIpThrottlerGuard). →@Throttle10/min (IP) auf dem Upload plus Nutzergrenze in der DB (höchstens 10 neue Notizen in 60 s jeownerUserId, gleiche Abfrage wie die Tagesgrenze). - Übernahme nur bei
READY(Spec 6.6) widerspricht dem Ziel „nach dem Speichern kein Inhalt mehr“: BeiTRANSCRIBED(Auswertung aus, Tageslimit),FAILEDmit 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) undFAILED;CONFIRMED/DISCARDED/EXPIREDergeben 409VOICE_CAPTURE_GONE. Laufende Phasen schließen per CAS aufstatus+claimTokenab und ändern eine bestätigte Notiz nicht. Gleiches Volk bleibt Pflicht (409). Inhaber (Frage A1). POST /voice-captures/discard-allsteht 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 EndpunktGET /voice-captures/config({ recordingEnabled, extractionEnabled, model, retention }), gesteuert überVOICE_RECORDING_ENABLED(ConfigMap, Standardfalse).- JobRun-Flut.
run-cli.tsschreibt je Lauf eineJobRun-Zeile; zwei Phasen × 720 Läufe/Tag = 1440 Zeilen/Tag, 90 Tage Aufbewahrung. →cleanuplöscht erfolgreichevoice-transcribe-Läufe ohne Arbeit (result->>'claimed' = '0'bzw.'ingested' = '0') nach 24 h; Läufe mit Arbeit oder Fehler bleiben 90 Tage. - Netzwerk-Wächter.
no-network-outside-loops.spec.tserlaubt Netz nur insrc/weather/,src/telegram/und Altdateien. → Ausnahme genau fürsrc/voice/extraction/anthropic-structured-model.tsund das Paket@anthropic-ai/sdk;src/jobs/**(also auchvoice-transcribe.job.ts) und*.handlers.tsdürfen es nicht importieren. - Magic Bytes:
matchesFileSignaturegibt für unbekannte Typentruezurück und kennt kein Audio. → Neue strikte FunktiondetectAudioType(buffer): VoiceAudioMime | nullinsrc/common/file-signature.ts(EBML1A 45 DF A3→audio/webm,OggS→audio/ogg,ftypan Byte 4 →audio/mp4);matchesFileSignaturebleibt unverändert. - Render-Stufen.
deploy/k8s/tests/render-stages.mjskennt eine festeROLLOUT_ORDER. → Neue Stufevoice(nachapi-hpa) inrender-stages.mjsund als auskommentierbare Komponente im Overlay. - 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(nienpm run lint, das Skript hat--fix); einzelne e2enpx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand; alle e2enpm run test:e2e -- --runInBand. Frontend: vor Next.js-Codenode_modules/next/dist/docs/lesen (AGENTS.md); Gatesnpm run lint && npm run typecheck && npx vitest run <pfade>, am Lieferungsendenpm testundnpm run build. Manifeste:node deploy/k8s/tests/manifest-policy.test.mjsundrender-stages.mjs. Whisper:pytestim Image-Build. - Die Test-DB ist nicht leer: Tests legen eigene Nutzer/Völker an, räumen
VoiceCapture*inbeforeEachauf 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.mdträ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; niedb push, niemigrate dev.
Fachwerte (exakt aus der Spec)
- Aufnahme: Mono,
audioBitsPerSecond: 24000, Typaudio/webm;codecs=opus, sonstaudio/mp4; höchstens 180 s, Hinweis bei 150 s „Noch 30 Sekunden“; Upload ≤ 2 MB (2097152 Byte), genau ein Teilaudio; Typ nur nach Magic Bytes, sonst 415UNSUPPORTED_AUDIO, zu groß 413TOO_LARGE. - Grenzen je Nutzer: ≤ 10 offene Notizen (
QUEUEDbisREADY), ≤ 30 neue je UTC-Tag, ≤ 10 neue je 60 s → 429TOO_MANY_OPEN„Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.“ Volk lebend (LIVING_COLONY_WHERE),inspectionDatehö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 gegennow(). - CronJob
apicore-job-voice-transcribe:*/2 * * * *,timeZone: Europe/Berlin,concurrencyPolicy: Forbid,startingDeadlineSeconds: 120,activeDeadlineSeconds: 1500; je Lauf ≤ 5 Aufnahmen und ≤ 600 s Ton;emptyDirmedium: Memory,sizeLimit: 32Mi. Ressourcen:fetch/ingest50m/500m, 192Mi/512Mi;whisper50m/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_promptausvoice-vocabulary.ts;NO_SPEECHbei Sprache ≠ de mit Wahrscheinlichkeit < 0,6 oder Text < 5 Zeichen;TOO_LONGbei gemessener Dauer > 240 s. - Claude:
VOICE_MODELStandardclaude-sonnet-5-5(Option für Kosten:claude-haiku-5-5, Option bei Qualitätsmangel:claude-opus-5-5), Kennungen nur invoice-config.ts;max_tokens2000,output_config: { effort: 'low', format: { type: 'json_schema', schema } }, nicht streamend, Zeitlimit 60 s, 2 SDK-Wiederholungen, keintool_choice, kein Prefill, keine Sampling-Parameter;fallbacks: "default"mit Beta-Headerserver-side-fallback-2026-07-01nur, wenn die aktuelle Anthropic-Doku es beim Bau bestätigt (sonst weglassen, Vermerk im Bericht). SDK-Debug-Log aus.VOICE_DAILY_EXTRACTIONSStandard 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 alsprevious. - Rechte:
voice-captures:use(Gruppe „Sprache“,sortOrder4000, höchster vorhandener Wert ist 3900); jeder Endpunkt zusätzlichinspections:create;GET /voice-captures/statsnursystem:manage. Fremde Ids → 404. AntwortenCache-Control: no-store. - Ereignisse:
VoiceCaptureReady { captureId, colonyId },VoiceCaptureFailed { captureId, code },AggregateType+'VoiceCapture'; nie Text; nie Webhooks. - Backup:
VoiceCapture,VoiceCaptureAudioinMODELS_EXCLUDED_FROM_BACKUPmit 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 überloggableError(). - Datensatz, Audit, Ereignis in einer Transaktion; Handler schreiben nur über
tx, ohne Netz (handler-contract.spec.tsdecktvoice.handlers.tsab). - Read-modify-write unter Zeilensperre:
VoiceCapturekommt ans Ende vonLOCKABLE_TABLES(nachComplianceAcknowledgement); Durchsicht sperrt (falls nötig)Colony, dannVoiceCapture. - Worker und Jobs nutzen
JobPermissionsModule; Module mit Handlern werden vonAppModuleundWorkerModuleimportiert. 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
- 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),VoiceCapturenicht in Suche/Export/Backup (Task 4). - 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). - Erfundenes oder Fremdes kommt nie ins Formular:
validateSuggestionsverwirft Zitat-lose, fremde Felder, Werte außerhalbFIELD_SPECS(Task 14); Formular belegt kein Feld ohne Klick (Task 20). - 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). - 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)), Musterplaceholder-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 annotes,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: truebeilocal = '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, wennapifalse 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.tsxbzw. 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; GegenrelationenUser.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; Erweiterungtest/search.e2e-spec.tsbzw. 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-Checknpx 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 offenertranscriptEnc/suggestionEncundVoiceCaptureAudio.dataEnc(nur Status ≠CONFIRMED/DISCARDED/EXPIRED), Zähler imJobResult. -
ensure-secrets.sh:VOICE_ENCRYPTION_KEY randominapicore-backend-secret; Kommentar: vor dem T7-Backend-Image ausführen (Widerspruch 8).secret.example.yaml: Schlüssel und Hinweis aufapicore-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 vonCreateInspectionDtoübergetMetadataStorage());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 mitsealVoiceBytes(…, 'capture:<id>:audio')inVoiceCaptureAudio, AuditCREATEohne Inhalt. Keine Datei auf der Platte. -
Statische Pfade (
config,stats,discard-all,text) vor:idregistrieren. -
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 mitContent-Type: image/png→ angenommen alsaudio/webm; PNG-Bytes mitaudio/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(importiertVoiceModulebzw. ein schlankesVoiceConfirmationModuleohne 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:voiceCaptureIdausdataherauslösen (wird nicht anInspectiongespeichert), nach dem Insert in derselben TransaktionconfirmIn. 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 invoice.module.ts;WorkerModuleimportiert ein Modul mit den Handlern (Task 15 trägt es ein, hier nurVoiceHandlersModulebereitstellen) - 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 ausVOICE_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_DISABLEDmeldet zusätzlich ansystem: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, neutest/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; e2efetch 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/voiceund 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, Erweiterungtest/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:
fetchmacht 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(schreibtApiCore-Whisper/vocabulary.txtausvoice-vocabulary.ts), Wächtersrc/voice/core/voice-vocabulary-export.spec.ts
Interfaces
-
ApiCore-Whisper/vocabulary.txtist eingecheckt; der Wächter vergleicht ihn mitwhisperInitialPrompt()(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(Komponentevoiceals letzte, Imagegit.encry.de/nick.rosenloecher/apicore-whispermit 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(Stufevoice)
Interfaces
-
transcribe.py: liest/work/in/manifest.json; ohne Manifest Exit 0 sofort; lädtWhisperModel('/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>.jsonnachWhisperResult; Dauer vor dem Abschreiben per PyAV messen (TOO_LONG> 240 s ohne Dekodieren des Rests); vor dem Modell-Ladensocket.socketgesperrt (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, Modelldateiensmallint8 nach/models/smallmit SHA-256-Prüfung im Build, kein Netz zur Laufzeit,pytestals Build-Stufe. CI: nur bei Änderungen unterApiCore-Whisper/, Tagsha-<commit>,trivywenn im Runner vorhanden. -
CronJob (vollständig, Widerspruch 10): Objekt-Label
app: apicore-voice-job, Pod-Labelsapp: apicore-job,apicore.io/job: voice-transcribe,db-client: "true"; Werte aus Global Constraints;restartPolicy: Never,backoffLimit: 0,ttlSecondsAfterFinished: 86400,automountServiceAccountToken: false, Pod-securityContextwie die anderen Jobs; Volumeswork(emptyDir: { medium: Memory, sizeLimit: 32Mi }) undtmp;initContainers: [fetch, whisper],containers: [ingest];fetch/ingest: Backend-Image,node dist/cli.js voice-transcribe --phase=…,APICORE_ROLE=job,envFromConfigMap +apicore-backend-secret;whisper: Imageapicore-whisper, keinenv/envFromaußerWHISPER_CPU_THREADS, nur Volumework(ganzer Mount auf/work, Schreibrecht nötig für/work/out),readOnlyRootFilesystem,runAsNonRootuid 1000,capabilities.drop: [ALL],seccompProfile: RuntimeDefault. -
patch-worker-voice.yaml: Worker bekommtANTHROPIC_API_KEYpersecretKeyRef(apicore-anthropic,optional: true); kein anderer Pod. -
manifest-policy.test.mjsneu: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 buildlokal (falls Docker fehlt: CI-Lauf im Bericht vermerken), Policy-Tests undrender-stages.mjsgrün. Orchestrator committetfeat: 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/sdkexakt; vorhersupply-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 undfallbacksgegenlesen (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(stelltSTRUCTURED_MODEL→ Anthropic-Adapter, Handler-Modul, Service bereit) - Modify:
src/worker/worker-runtime.service.ts(+ Spec: Schleife),src/worker/worker.module.ts(importiertVoiceWorkerModule),src/runtime/startup-checks.ts(ruftassertAnthropicKey) – Hinweis:startup-checks.tsgehö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, Erweiterungtest/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);drainclaimt je Runde 1, nacheinander, bis nichts mehr da odersignalabgebrochen. -
Je Notiz: Schlüssel fehlt (
configured === false) → beim Start einmal „voice extraction disabled“, Notizen bleibenTRANSCRIBED(deferVoiceClaim1 h),lastErrorCode EXTRACT_DISABLED, Meldung ansystem:manageeinmal; Tageslimit (count(*)heutiger UTC-Tag mitmodel IS NOT NULL) ≥dailyExtractions→deferVoiceClaimbis Mitternacht UTC,DAILY_LIMIT; sonstopenTranscript→extract(Modell ausvoiceConfig,max_tokens2000, 60 s, 2 Wiederholungen) →ok+validateSuggestions≠ null → in einer TransaktionsuggestionEnc = sealVoiceText(JSON, 'capture:<id>:suggestion'),model, Tokens,READY,readyAt, EreignisVoiceCaptureReady(CAS);refused→FAILED/REFUSED+VoiceCaptureFailed;truncated/null →settleVoiceFailure(EXTRACT_INVALID);RETRYABLE→settleVoiceFailure(EXTRACT_RETRY), beim 3. MalEXTRACT_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): ruftexpireVoiceContent,deleteOldVoiceRows, löscht leerevoice-transcribe-JobRuns > 24 h (Widerspruch 16), Zähler imJobResult(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.jsonSkripteval:voice - Test:
voice-cleanup.spec.ts
Interfaces
-
eval:voiceruft den echten Adapter nur mitANTHROPIC_API_KEYund--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 rufteval: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; Storepending-voicemit IndexownerUserId)
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,isCancelledbeachtet),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 mitvoice-captures:useundrecordingEnabled),src/components/rounds/round-next-panel.tsxbzw. 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 überColonySelectField/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 beiprefers-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:RecordingNoticeDialogmit Spec-9.5-Text. -
/voice: Karten (Volk, Zeit, Status mit Symbol und Wort ausVOICE_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“ beiFAILEDmit 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,voiceCaptureIdim Input, nicht im Entwurf),src/lib/inspections/types.ts(CreateInspectionInput.voiceCaptureId?: string),src/lib/offline/sync.tsnicht (409-Weg siehe unten überuseCaptureMutation) - 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 einemrole="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),unclearals „Das konnte ich nicht zuordnen“ ohne Knopf,dropped > 0und 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ädtfetchVoiceCapture, 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 schicktvoiceCaptureId; offline gespeichert geht die Id mit in die Warteschlange; Antwort 409VOICE_CAPTURE_GONE(online oder beim Sync) → genau ein erneuter Versuch ohnevoiceCaptureIdmit denselben Eingaben und neuem Idempotency-Key, Hinweis „Sprachnotiz war abgelaufen, Durchsicht ohne sie gespeichert“; 409VOICE_CAPTURE_COLONY_MISMATCH→ Fehlertext, nichts gespeichert, Angebot „Ohne Sprachnotiz speichern“. Nach ErfolgvoiceKeys.allinvalidieren. -
Sync-Seite des 409-Wegs:
drainfürpending-inspectionsbekommt einen HookretryWithout(entry, error)(kleine Erweiterung insync.ts) – Dateikonflikt mit Task 18: läuft in W7 nach W6, also zulässig; Testa 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“ aususeDictationgemeldet), 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 inVoiceCaptureBox(Abfrage alle 5 s bisREADY/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 ausfetchVoiceConfig, „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 TabelleVOICE_MODEL_PRICES_USDinlib/voice/prices.ts, nur mitsystem:manage),src/lib/system/labels.ts(+ Spec:DOMAIN_EVENT_LABELSfürVoiceCaptureReady„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-capturesnie 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überVOICE_FAKE_MODEL=1nur 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 committetfeat: 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 schreibtWhisperResult→ingest→TRANSCRIBED, Audio-Zeile weg; Schleife mitFakeStructuredModel→READYund genau eine Meldung nachdrainDomainEvents;POST /inspectionsmitvoiceCaptureId→CONFIRMED,transcriptEncundsuggestionEncnull, Audio weg; fremder Nutzer 404; READONLY und API_CLIENT 403; zwei parallelefetch-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 odermedium(Request 2Gi) nur nach Freigabe. - Schritt 2:
npm run eval:voice -- --yes(kostet Geld, nur nach Freigabe) mitclaude-sonnet-5-5; nur wenn Kosten Thema werden zusätzlichclaude-haiku-5-5; verfehlt Sonnet die Ziele →VOICE_MODEL=claude-opus-5-5vorschlagen. Ergebnis inprogress.md.
Task 25: Abschluss und Rollout (Orchestrator)
- Schritt 1: Alle Gates Backend, Frontend, Manifeste, Whisper-CI;
code-simplifierauf 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 Komponentevoice, Pod-Aufbau, Secretapicore-anthropic; §10 Schleifevoice-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 -kvorher): 0. Inhaber: Anthropic-Schlüssel, Ausgabenlimit (Vorschlag 10 $/Monat), DPA, Datenschutzerklärung;ensure-secrets.shergänztVOICE_ENCRYPTION_KEY(vor Schritt 1, Widerspruch 8); Inhaber legtapicore-anthropican. 1. Backend-Image mit Migration (VOICE_RECORDING_ENABLED=false, Auswertung ohne Schlüssel aus). 2.apicore-whisperbauen, Digest ins Overlay. 3. Komponentevoiceeinschalten; erster Laufkubectl 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, Komponentevoiceaus; offene Notizen laufen überexpireVoiceContentab.
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:
colonyStrengthundbroodFramesbis 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 lieberSYSTEM? 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).