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