docs: ARCHITECTURE §14 Sprache (T7), Annahmen 107–118, TASKS T7-Stand

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:09:55 +02:00
1 parent f6e1f34baa
commit a7a7599304
2 files changed
+123 -3

No files matched your search

+116
View File
@@ -229,6 +229,18 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1.
105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1.
106. **Restrisiko im Abschreib-Pod wird getragen**: Der Container `whisper` (CronJob `voice-transcribe`, Komponente `deploy/k8s/components/voice`) dekodiert nicht vertrauenswürdige Audiodateien mit libav/PyAV und CTranslate2. Er teilt das Pod-Netz; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Ein kompromittierter Decoder könnte über DNS-Anfragen langsam Daten nach außen tragen und Postgres erreichen, hat aber keine Zugangsdaten (kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, eigenes `/tmp`, damit er `ingest` mit den Secrets nichts unterschieben kann). Er sieht Ton und Abschriften seines Laufs (höchstens 5 Aufnahmen, 600 s) und könnte deren Ergebnisse verfälschen; `/work/in` ist für ihn nur lesbar, und `ingest` übernimmt nur Ergebnisse zu Aufnahmen des eigenen Manifests mit passendem `claimToken`, prüft jedes Ergebnis streng, und nichts geht ohne Klick in ein Formular. Akzeptiert nach Spec 9.2: ein Betrieb mit eigenen Nutzern, keine Geheimnisse im Decoder, kein Internet, Schaden auf einen Lauf begrenzt. Das CronJob-Objekt trägt bewusst `app: apicore-voice-job` (nur das Pod-Template `app: apicore-job`), damit der gemeinsame Job-Patch keinen weiteren Container einsetzt; `av` ist wegen faster-whisper 1.2.1 auf 18.1.0 festgelegt. Kosten bei falscher Annahme: Abschriften eines Laufs könnten bei einer Lücke im Decoder abfließen oder vertauscht werden. Stärkere Optionen später: eigener Pod für den Decoder mit deny-all-NetworkPolicy (auch ohne DNS) und Übergabe-Volume, oder ein Subprozess je Aufnahme mit eigener Sandbox.
107. **Speichern der Durchsicht bestätigt eine Sprachnotiz in jedem offenen Zustand**: Auch eine Notiz, die noch abgeschrieben oder ausgewertet wird, wird mit dem Speichern `CONFIRMED` und ihr Inhalt in derselben Transaktion gelöscht; ein laufender Job verliert seinen Anspruch. Grund: Wer speichert, hat entschieden, und Inhalte sollen nie liegen bleiben. Kosten bei falscher Annahme: Eine noch laufende Abschrift oder Auswertung geht verloren, wenn der Nutzer vorher speichert; die Notiz lässt sich nicht mehr nutzen und muss neu aufgenommen werden. Abschnitt 14.2.
108. **Eine Dezimalzahl bleibt beim Zitatabgleich ein Wort**: `4,5` und `4.5` gelten als dieselbe Zahl und werden nicht an Komma oder Punkt zerlegt, sonst ließe sich ein Zitat aus Zahlenresten zusammensetzen, das nie gesagt wurde. Kosten bei falscher Annahme: Ein Zitat mit anderer Schreibweise der Dezimalzahl trifft die Abschrift nicht, ein gültiger Vorschlag wird verworfen (der Nutzer trägt von Hand ein). Abschnitt 14.4.
109. **Wiederholen wird über Grenzen gebremst, nicht über eine Zahl je Notiz**: Jedes Wiederholen reiht die Notiz neu ein und kann einen bezahlten Aufruf auslösen. Gebremst wird es durch die Sperre je Nutzer, die Grenze von 10 offenen Notizen und höchstens 5 Anfragen je Minute und Netz; eine Obergrenze je Notiz gibt es nicht (sie bräuchte eine Schemaänderung). Kosten bei falscher Annahme: Ein Nutzer mit einer dauerhaft kaputten Notiz kann sie wiederholt neu starten und Tagesbudget verbrauchen; Abhilfe wäre ein Zähler für manuelle Wiederholungen mit Migration. Abschnitt 14.1.
110. **Das Tagesbudget zählt bezahlte Aufrufe im Protokoll, nicht Notizen**: `VoiceExtractionCall` hält jeden Aufruf mit Ausgang; `AUTH` und `ABORTED` zählen nicht, abgelehnte, abgeschnittene, ungültige und wiederholte Aufrufe schon, weil sie Geld kosten. Die Tabelle ist nicht im Backup und wird nach 90 Tagen gelöscht. Kosten bei falscher Annahme: Bei vielen Fehlversuchen ist das Budget früher erschöpft, als die Zahl der Notizen vermuten lässt, und Notizen warten bis zum nächsten Tag; eine weitere Tabelle ist zu pflegen. Abschnitt 14.4.
111. **Das Tagesbudget wechselt um 00:00 UTC**: Die Grenze ist der UTC-Tag der Datenbankuhr (wie die 30 neuen Notizen je Tag), also 01:00 oder 02:00 Uhr in Berlin. Kosten bei falscher Annahme: Wer in der Nacht arbeitet, sieht das Limit nicht zum Berliner Tageswechsel zurückgesetzt; für einen Betrieb mit Tagesarbeit ohne Folgen, sonst ein Wechsel der Grenze auf den Berliner Tag. Abschnitt 14.4.
112. **Eine von selbst beendete Aufnahme wird sofort gespeichert**: Erreicht die Aufnahme 180 s, geht sie ohne Rückfrage in die Geräte-Warteschlange; ein Verlassen der Seite verwirft sie nicht. Kosten bei falscher Annahme: Eine Aufnahme, die der Nutzer nicht wollte, liegt in der Warteschlange und wird hochgeladen, bis er sie verwirft; sie kostet Speicher und Abschreibzeit, aber keine Auswertung, solange er sie nicht öffnet. Abschnitt 14.6.
113. **Der Schalter wirkt auch im Server**: Bei `VOICE_RECORDING_ENABLED=false` lehnen Upload und Text-Weg mit 403 `VOICE_RECORDING_DISABLED` ab, nicht nur die Oberfläche. Lesen, Wiederholen und Verwerfen bleiben. Kosten bei falscher Annahme: Notizen, die offline vor dem Ausschalten aufgenommen wurden, werden beim Sync abgelehnt und bleiben mit Fehler in der Warteschlange (der Nutzer verwirft sie); der Rückweg ist damit sauber, aber nicht verlustfrei. Abschnitt 14.1.
114. **Kosten werden für die letzten 7 Tage gezeigt, zum Preis des eingestellten Modells**: Die Zähler der Systemseite kennen nur ein 7-Tage-Fenster, und die Preistabelle liegt in zwei Dateien (`lib/voice/prices.ts`, `src/voice/eval/voice-prices.ts`, ein Paritätstest). Kosten bei falscher Annahme: Der Inhaber liest eine Wochen-, keine Monatssumme; die echte Abrechnung steht in der Anthropic-Konsole. Abschnitt 14.5.
115. **Eine abgelehnte Anfrage darf serverseitig auf einem anderen Modell laufen**: Mit `VOICE_SERVER_FALLBACK=default` (Voreinstellung) setzt der Adapter für Sonnet und Opus den Ausweichweg von Anthropic; gebucht wird das tatsächlich antwortende Modell. Kosten bei falscher Annahme: Eine Notiz wird auf einem anderen, womöglich teureren Modell beantwortet als eingestellt, und das Tagesbudget zählt Aufrufe, nicht Euro; abschaltbar mit `off`, dann endet die Notiz bei einer Ablehnung `FAILED/REFUSED`. Abschnitt 14.4.
116. **Der Text einer Sprachnotiz darf zu Anthropic (Drittland)**: Der Inhaber hat den API-Schlüssel angelegt (2026-10-16); das gilt als Freigabe von Spec-Frage 3. Modell `claude-sonnet-5-5` mit `effort: low` (Frage 1); die Cloud-Erkennung des Browsers bleibt ohne Einwilligung aus (Frage 2). Eine Region ist nicht gesetzt (`inference_geo` bleibt aus), es gilt die Voreinstellung des Anthropic-Arbeitsbereichs. Kosten bei falscher Annahme: Fehlt die Freigabe, bleibt der Worker ohne das Secret `apicore-anthropic`, die Notizen bleiben `TRANSCRIBED` und sind als Text nutzbar; Datenschutzerklärung und Verzeichnis müssen die Übermittlung vor dem Einschalten nennen. Abschnitt 14.7.
117. **Die Grenzen der Vorschläge folgen dem Formular, nicht der Spec**: Volksstärke bis 40 und fünf Futterstufen, wie `CreateInspectionDto` und das Formular; für `broodFrames` ohne Obergrenze im Formular gilt im Sprachweg 40. Kosten bei falscher Annahme: Die Auswertung schlägt Werte vor, die der Autor der Spec nicht erwartet hat, oder verwirft zulässige (sie landen als Zahl in `dropped`); die Grenzen stehen an einer Stelle (`core/inspection-field-specs.ts`). Abschnitt 14.4.
118. **Meldungen zu Sprachnotizen gehören in die Kategorie „Vorschläge“**: „Sprachnotiz ist fertig“ und „konnte nicht ausgewertet werden“ haben die Kategorie `SUGGESTION`, weil sie zu Vorschlägen führen. Kosten bei falscher Annahme: Wer die Kategorie „Vorschläge“ in Telegram oder der App abschaltet, bekommt auch diese Meldungen nicht mehr; die Notiz bleibt unter `/voice` sichtbar. Abschnitt 14.5.
## 7. Kubernetes
@@ -488,6 +500,8 @@ Das Ergebnis zählt jede Kategorie (`idempotencyRecords`, `consumedTokens`, `thr
**`rekey`** (nur von Hand, CronJobs `apicore-job-rekey` und `apicore-job-rekey-dry-run` mit `suspend: true`): siehe Annahme 22.
**`voice-transcribe`** (CronJob alle 2 Minuten, Phasen `--phase=fetch` und `--phase=ingest` um den Whisper-Container): Abschnitt 14.3.
**Tests.** `test/jobs.e2e-spec.ts` ruft `runCli([...])` mit dem echten `JobsModule` auf, wie `node dist/cli.js`. `test/daily-evaluation.e2e-spec.ts` ruft `TimeEventsStep.run(Tag)` mit Tagen in den Jahren 2001 und 2002 auf: Der Schritt wertet die ganze geteilte Test-DB aus, so bleiben die Ereignisse paralleler e2e-Dateien unberührt (und es entstehen keine `JobRun`-Zeilen, die `jobs.e2e-spec.ts` zählt). `test/rekey.e2e-spec.ts` setzt nur `*_PREVIOUS`, weil der Job jede Zeile der geteilten Test-DB besucht und die anderen e2e-Prozesse mit denselben aktuellen Schlüsseln weiterlesen müssen.
### System-Seite
@@ -1028,3 +1042,105 @@ Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Ope
- **Durchsicht.** `inspections/new` hat den Abschnitt „Beute“ (`hive-section.tsx`): die Karte im Modus `inspection` sammelt Operationen als Entwurf, sie gehen als `hiveChange` mit der Durchsicht in `POST /inspections` und in der Warteschlange mit dem Eintrag (Abschnitt 2). Die Sheets stoppen das `submit` an ihrem Formular (`stopPropagation`; sie liegen in einem Portal, doch React reicht Ereignisse im Komponentenbaum weiter), sonst würde „Speichern“ im Sheet die Durchsicht speichern; neue Formulare in Sheets müssen dasselbe tun. Zusätzlich stoppt der Abschnitt selbst `submit` an seinem Wrapper (`hive-section.tsx`).
- **3D-Szene.** `components/hive/hive-scene` (three, react-three-fiber, drei, postprocessing) wird nur über `next/dynamic` mit `ssr: false` geladen, sobald die Karte sichtbar wird (`IntersectionObserver`); andere Seiten laden nichts davon. Budget für den Chunk: höchstens 320 kB gzip (Spec 9.7); gemessen zuletzt etwa 296 kB. Die Szene misst die Bildrate (`quality.ts`): unter 45 fps fällt sie auf die Stufe `low` (kein Spiegelboden, kein MSAA, keine Tiefenunschärfe, keine Partikel), die Tiefenunschärfe läuft erst ab 55 fps. Bewegung folgt der Stufe `full`, `subtle` oder `off` (`useEffectiveMotionLevel`: Flüge, Überblendung 400, 150 oder 0 ms). **Rückfall auf das SVG** (`shouldUse3D`, `lib/hive/webgl.ts`): kein WebGL, erzwungene Farben, oder Bewegung `off` bei höchstens 2 Prozessorkernen; ein verlorener WebGL-Kontext baut die Szene einmal neu auf, der zweite lässt es beim SVG. Das SVG ist darunter immer gerendert und bleibt bedienbar, bis das erste 3D-Bild steht; im Druck entfällt die 3D-Ebene.
## 14. Sprache (T7 „Freihand“)
Spracheingabe für die Durchsicht am Stand, in drei Stufen, die einzeln abschaltbar sind. Spec: `docs/superpowers/specs/2026-10-14-t7-sprache-design.md`, Plan: `docs/superpowers/plans/2026-10-16-t7-sprache.md`. Eine Sprachnotiz füllt nie etwas selbst aus: Vorschläge landen erst nach einem Tipp im Formular (Abschnitt 14.6). Inhalte (Ton, Abschrift, Vorschlag) sind personenbezogen und leben nur verschlüsselt und nur kurz; Protokolle, Audit-Einträge und Ereignisse tragen nie Inhalt, nur Ids, Zähler und feste Codes.
**Stufen.**
1. **Diktat im Browser.** Die Spracherkennung des Browsers (`components/voice/use-dictation.ts`) schreibt in Textfelder der Durchsicht. Die Cloud-Erkennung mancher Browser ist ohne Einwilligung aus (`lib/voice/consent.ts`, nur `localStorage`). Kein Server beteiligt.
2. **Sprachnotiz mit Abschrift im Cluster.** Das Gerät nimmt bis zu 180 s auf, lädt die Aufnahme hoch, und der CronJob `apicore-job-voice-transcribe` schreibt sie mit Whisper ab (Abschnitt 14.3). Das Ergebnis ist ein Text (Status `TRANSCRIBED`), der im Formular nutzbar ist, auch ohne Stufe 3.
3. **Auswertung mit Claude.** Die Worker-Schleife `voice-extractions` ruft Claude, prüft die Antwort gegen die Abschrift und legt Vorschläge ab (Status `READY`, Abschnitt 14.4). Ohne Schlüssel bleibt die Notiz bei `TRANSCRIBED`.
**Datenmodell** (Migration `20261019100000_t7_voice_captures`).
- `VoiceCapture`: Besitzer, Volk, Berliner Tag der Durchsicht (`inspectionDate`), `source` (`AUDIO` oder `TEXT`), `status`, `attempts`, `availableAt`, `claimedAt`, `claimToken`, `lastErrorCode`, Spalten `transcriptEnc` und `suggestionEnc` (Chiffrat), Modell und Tokens, `contentExpiresAt` (Anlage + 7 Tage), `contentDeletedAt`, `inspectionId` (ohne Fremdschlüssel; ein CHECK bindet `CONFIRMED` an eine gesetzte `inspectionId`). `VoiceCaptureAudio` hält den Ton getrennt (`dataEnc`, höchstens 2 MB Klartext), damit Löschen ein `DELETE` ist und Listen den Ton nie laden.
- `VoiceExtractionCall`: Protokoll bezahlter Modellaufrufe (Modell, Token, `outcome`, optional `captureId`), ohne Inhalt und ohne Fremdschlüssel. Das Tagesbudget und die Kostenkarte lesen es, nicht die Notizen.
- Status: `QUEUED` → `TRANSCRIBING` → `TRANSCRIBED` → `EXTRACTING` → `READY` → `CONFIRMED`; dazu `DISCARDED`, `FAILED` (Code in `lastErrorCode`) und `EXPIRED`. Der Text-Weg beginnt bei `TRANSCRIBED`. Feste Fehlercodes: `src/voice/voice-error-codes.ts`; deutsche Texte: `voice-error-texts.ts`.
- `VoiceCapture`, `VoiceCaptureAudio` und `VoiceExtractionCall` stehen im Backup auf der Ausschlussliste (flüchtig, personenbezogen, nach einer Wiederherstellung nicht zustellbar). In der Sperrreihenfolge ist `VoiceCapture` die letzte Tabelle (`row-lock.ts`).
**Verschlüsselung.** Alles Inhaltliche liegt mit dem Schlüssel `VOICE_ENCRYPTION_KEY` (Zweck `voice`, Annahme 22) versiegelt. Die GCM-AAD bindet jeden Wert an seine Zeile und Art: `capture:<id>:transcript`, `capture:<id>:suggestion`, `capture:<id>:audio` (`voiceAad` in `core/voice-crypto.ts`). Ein in eine andere Notiz oder Art kopiertes Chiffrat öffnet sich nicht. Text hat das Format der anderen Zwecke; Ton ist `[Version][Schlüssel-Id][IV][Tag][Daten]`. Der Job `rekey` versiegelt nur offene Notizen neu.
**Aufbewahrung und Löschen.**
- Ton: gelöscht, sobald die Abschrift gespeichert ist, spätestens nach 24 h; eine Notiz, die dann noch auf ihre Abschrift wartet, endet `FAILED` mit `AUDIO_EXPIRED`.
- Inhalte (Abschrift, Vorschlag): spätestens 7 Tage (`contentExpiresAt`), dann `EXPIRED`. Inhalte deaktivierter Nutzer gehen mit derselben Regel.
- Zeilen und Protokoll: 90 Tage (`deleteOldVoiceRows`, `deleteOldVoiceCalls`); leere Läufe des Abschreib-Jobs räumt `deleteIdleVoiceRuns` nach einem Tag aus `JobRun`.
- Die reinen SQL-Regeln (`expireVoiceContent`, Datenbankuhr, kein Schlüssel nötig) laufen in jedem Lauf der Phase `fetch`, also alle 2 Minuten, und zusätzlich im täglichen `cleanup`, das jede Kategorie zählt (`voice.audioExpired`, `voice.contentExpired`, `voice.inactiveOwnersWiped`, `voice.rowsDeleted`, `voice.callsDeleted`).
- Sofort gelöscht: bei Übernahme (Durchsicht gespeichert, Abschnitt 14.2), bei „Verwerfen“, bei „Alle löschen“ (`POST /voice-captures/discard-all`) und, auf dem Gerät, beim Abmelden (Abschnitt 14.6).
### 14.1 Rechte, Routen und Grenzen
**Rechte.** Alle Routen außer den Zählern brauchen `voice-captures:use` (Gruppe „Sprache“; die Migration vergibt es an ADMIN, BEEKEEPER und STAFF, nicht an READONLY und API_CLIENT) und zusätzlich `inspections:create` (`InspectionCreateGuard`): wer keine Durchsicht anlegen darf, bekommt keine Sprachnotizen. `GET /voice-captures/stats` verlangt `system:manage`. Jede Abfrage filtert auf den Besitzer; eine fremde Id ist 404. Antworten tragen `Cache-Control: no-store`.
**Routen** (`src/voice/captures/voice-captures.controller.ts`; statische Pfade vor `:id`).
- `POST /voice-captures`: Aufnahme hochladen (multipart `audio`, `colonyId`, `inspectionDate`, `durationSec` 1 bis 240), `@Idempotent()`, Antwort `{ id, status: 'QUEUED' }`. Multer hält die Datei im RAM (`fileSize` 2 MB, ein Teil); der Typ kommt aus den ersten Bytes (WebM, Ogg, MP4), nie aus dem `Content-Type` (415 `UNSUPPORTED_AUDIO`, 413 `TOO_LARGE`).
- `POST /voice-captures/text`: Text zur Auswertung (`colonyId`, `inspectionDate`, `text`, nach NFKC und ohne Steuerzeichen 20 bis 4000 Zeichen), `@Idempotent()`, Status `TRANSCRIBED`.
- `GET /voice-captures?status=` (eigene Notizen ohne Inhalt, höchstens 200), `GET /voice-captures/:id` (bei `READY`, `TRANSCRIBED` und `FAILED` mit Abschrift und Vorschlägen; ändert nichts), `GET /voice-captures/config`, `GET /voice-captures/stats`.
- `POST /voice-captures/:id/retry`: nur bei `FAILED` mit vorhandenem Text oder frischem Ton (unter 24 h) und `contentExpiresAt` in der Zukunft; zurück auf `TRANSCRIBED` oder `QUEUED`, `attempts = 0`; sonst 409 `VOICE_CAPTURE_GONE`. `POST /voice-captures/:id/discard` (204, idempotent) und `POST /voice-captures/discard-all`.
- `POST /inspections` nimmt `voiceCaptureId`; `VoiceConfirmationService.confirmIn` läuft als Letztes in der Anlege-Transaktion (Annahme 107). Eine fremde, fehlende oder abgeschlossene Notiz ist 409 `VOICE_CAPTURE_GONE`, eine Notiz eines anderen Volks 409 `VOICE_CAPTURE_COLONY_MISMATCH`; eine Wiederholung mit demselben `Idempotency-Key` ändert nichts.
**Grenzen.** Je Nutzer höchstens 10 offene Notizen (`QUEUED` bis `READY`), 30 neue je UTC-Tag und 10 neue je Minute, unter einer Advisory-Sperre je Nutzer (sonst 429 `TOO_MANY_OPEN`, Text „Zu viele offene Sprachnotizen. Erst auswerten oder verwerfen.“); ein Wiederholen zählt wie eine neue Notiz. Dazu Throttle je Netz: 10/min für Upload, 5/min für Wiederholen (Annahme 109). Das Volk muss leben (400), der Tag der Durchsicht höchstens 7 Tage zurück und nicht in der Zukunft.
**Schalter.** `VOICE_RECORDING_ENABLED` (ConfigMap `apicore-backend-config`, Voreinstellung `false`) steuert die Aufnahme. Aus: Upload und Text-Weg antworten 403 `VOICE_RECORDING_DISABLED`, die Oberfläche blendet die Einstiege aus; Liste, Lesen, Wiederholen und Verwerfen bleiben (Annahme 113). `GET /voice-captures/config` meldet `recordingEnabled`, `extractionEnabled` (der API-Pod kennt den Schlüssel nicht und schließt „aus“ daraus, dass der Worker in der letzten Stunde eine Notiz mit `EXTRACT_DISABLED` geparkt hat), das Modell und die Fristen (24 h, 7 Tage, 90 Tage). Weitere Einstellungen: `VOICE_MODEL` (`claude-sonnet-5-5`, `claude-haiku-5-5`, `claude-opus-5-5`; die Modell-Ids stehen nur in `voice-config.ts`), `VOICE_DAILY_EXTRACTIONS` (60, erlaubt 1 bis 10000), `WHISPER_MODEL` (`small` oder `medium`), `VOICE_SERVER_FALLBACK` (`default` oder `off`). Ungültige Werte verhindern den Start.
**Ansprüche, Wiederholungen, Verfallsfenster.** Beide Phasen teilen sich die Hilfen in `claims/voice-claims.ts` und die Regeln in `claims/voice-phases.ts`. Ein Lauf beansprucht eine Notiz atomar (`claimToken`); Abschluss, Fehlversuch und Verschieben prüfen den Token (Compare-and-Set), sodass ein Prozess, der den Anspruch verloren hat, nichts mehr schreibt. Ein Abschluss der Phase `transcribe` setzt `attempts` auf 0, damit `extract` seine eigenen 3 Versuche hat.
| Phase | Von → in Arbeit → fertig | Versuche | Wartezeit nach Fehlversuch | Anspruch veraltet nach | Endcode |
|---|---|---|---|---|---|
| `transcribe` | `QUEUED` → `TRANSCRIBING` → `TRANSCRIBED` | 3 | 5 min, 15 min | 30 min | `TRANSCRIBE_FAILED` |
| `extract` | `TRANSCRIBED` → `EXTRACTING` → `READY` | 3 | 30 s, 2 min | 5 min | `EXTRACT_FAILED` |
Ein veralteter Anspruch geht beim nächsten Lauf zurück und zählt einen Versuch; ist der letzte verbraucht, endet die Notiz `FAILED` und `VoiceCaptureFailed` wird geschrieben.
### 14.2 Übernahme
Das Speichern der Durchsicht bestätigt die Notiz in jedem offenen Zustand: Status `CONFIRMED`, `inspectionId` gesetzt, Ton, Abschrift und Vorschlag gelöscht, alles in der Transaktion der Durchsicht. Ein laufender Job verliert seinen Anspruch und kann nicht mehr abschließen. Scheitert die Bestätigung, scheitert das ganze Speichern.
### 14.3 Abschreiben im Cluster
**Job.** `voice-transcribe` (`src/jobs/voice-transcribe.job.ts`, Argumente `--phase=fetch` und `--phase=ingest`) ist die Node-Hälfte um den Whisper-Container; Komponente `deploy/k8s/components/voice`, CronJob `apicore-job-voice-transcribe`: `*/2 * * * *`, `Europe/Berlin`, `concurrencyPolicy: Forbid`, `backoffLimit: 0`, `activeDeadlineSeconds: 1500`. Drei Container laufen nacheinander mit derselben uid 1000 und fsGroup 1000:
1. `fetch` (initContainer, Backend-Image, Rolle `job`): wendet die Aufbewahrungsregeln an, gibt veraltete Ansprüche frei, beansprucht bis zu 5 Notizen mit zusammen höchstens 600 s Ton, entschlüsselt in den Speicher und legt Ton und Manifest nach `/work/in`.
2. `whisper` (initContainer, Image `apicore-whisper` aus `ApiCore-Whisper/`, faster-whisper auf CPU, `int8`, Deutsch, `beam_size` 1, VAD-Filter, 2 Threads): liest `/work/in` (nur lesbar), schreibt je Notiz ein Ergebnis nach `/work/out`. Kein `envFrom`, kein `secretKeyRef`, kein Service-Account-Token, kein Datenbankzugang; zusätzlich sperrt das Skript `socket.socket`. Das Modell steckt mit Prüfsummen im Image; `av` ist auf 18.1.0 festgelegt (Annahme 106).
3. `ingest` (Hauptcontainer, Backend-Image): übernimmt nur Ergebnisse zu Notizen des eigenen Manifests mit passendem `claimToken`, prüft sie streng, versiegelt den Text, löscht den Ton, setzt `TRANSCRIBED` und weckt den Worker (`pg_notify('voice_extraction')`), je Notiz in einer eigenen Transaktion. Zu kurzer Text (unter 5 Zeichen) ist `NO_SPEECH`, mehr als 240 s `TOO_LONG`; beide enden die Notiz sofort `FAILED`. Ein nicht dekodierbarer Ton zählt als Fehlversuch (`TRANSCRIBE_FAILED`).
**Härtung.** `/work` ist ein `emptyDir` mit `medium: Memory` (32Mi): entschlüsselter Ton und Abschriften berühren nie eine Platte; die Verzeichnisse sind 0700, die Dateien 0600. `fetch` und `ingest` teilen ein `/tmp` (64Mi), `whisper` hat ein eigenes im RAM (16Mi), damit ein kompromittierter Decoder `ingest` (das alle Secrets lädt) keinen Code unterschieben kann. Das Pod-Template trägt `app: apicore-job` und `db-client: "true"`; die NetworkPolicy `apicore-jobs` erlaubt nur Postgres (5432) und DNS. Das CronJob-Objekt selbst trägt `app: apicore-voice-job`, damit der gemeinsame Job-Patch keinen weiteren Container `job` einsetzt. Whisper: Request und Limit je 1280Mi Speicher (Messung vor der Freigabe, Abschnitt 14.7). Das Restrisiko eines gemeinsamen Pod-Netzes steht in Annahme 106.
### 14.4 Auswertung mit Claude
**Schleife.** `VoiceExtractionService` (`src/voice/extraction/`) läuft als Worker-Schleife `voice-extractions` (15 s Sicherheitsabfrage, sofort bei `voice_extraction`, ein Lauf zur Zeit, höchstens 20 Notizen je Lauf). Der Anspruch, das Ergebnis und das Ereignis einer Notiz committen zusammen.
**Aufruf.** Der Adapter `AnthropicStructuredModel` ist die einzige Stelle, die das SDK einbindet (`@anthropic-ai/sdk` 0.131.0, exakt festgelegt; eine Netz-Wächterprüfung sichert das). Der Endpunkt ist fest `https://api.anthropic.com`; `ANTHROPIC_BASE_URL` und `ANTHROPIC_CUSTOM_HEADERS` aus der Umgebung werden mit Warnung ignoriert. Je Aufruf `max_tokens` 2000, Timeout 60 s, 2 SDK-Wiederholungen, insgesamt höchstens 240 s, `effort: low`. Der Schlüssel `ANTHROPIC_API_KEY` kommt nur im Worker an (Secret `apicore-anthropic`, optional, einzelne Variable per `patch-worker-voice.yaml`); API- und Job-Pods warnen beim Start, wenn sie ihn trotzdem sehen.
**Prompt als Daten.** Der Systemprompt ist fester Text ohne Datum, Volk, Nutzer oder Modell-Id (`voice-prompt.ts`, `VOICE_PROMPT_VERSION`): Feldliste mit Wertebereichen aus dem Formular, die vier Skalen mit Ankerwörtern, die Futtercodes, neun Regeln (nur Ausdrückliches, Verneinung ist ein Wert, wörtliches Zitat, keine Namen, kein Wetter, Korrektur gewinnt) und die Fachwortliste. Die Abschrift steht in `<transkript>…</transkript>` und gilt als Datenmaterial, nie als Anweisung; `<` und `>` im Text werden zu ‹ und ›, damit sie den Rahmen weder schließen noch öffnen.
**Schema und Prüfung.** Strukturierte Ausgabe `{ findings: [{ field, value, quote, corrected }], unclear: [] }` (`voice-schema.ts`, ohne Grenzen wie `maxItems`, die das Schema nicht zulässt). Der Server prüft jede Antwort (`validate-suggestions.ts`, rein und deterministisch): höchstens 20 Einträge und 5 Unklarheiten (je 300 Zeichen); Feld und Wert gegen die Regeln des Formulars (`core/inspection-field-specs.ts`: Bereiche, Skalen 1 bis 5, Futtercodes, gekürzte Texte); das Zitat (höchstens 120 Zeichen) muss wörtlich in der Abschrift stehen, wobei eine Dezimalzahl ein Wort bleibt (Annahme 108); je Feld bleibt ein Wert (eine Korrektur schlägt eine Nichtkorrektur, dann das spätere Zitat), der stärkste andere Wert wird als `previous` mitgegeben. Verworfene Einträge zählen nur als Zahl (`dropped`, `truncated`). Das Ergebnis `{ suggestions, unclear, dropped, truncated }` liegt versiegelt in `suggestionEnc`.
**Budget und Ausgänge.** Das Tagesbudget zählt bezahlte Aufrufe aus `VoiceExtractionCall` auf dem UTC-Tag der Datenbankuhr, alle Ausgänge außer `AUTH` und `ABORTED` (Annahmen 110 und 111). Ist es erreicht, wartet die Notiz bis zum Tagesende (`DAILY_LIMIT`, kein Fehlversuch). Ein Aufruf wird vor dem Abschluss der Notiz gebucht, damit ein verlorener Anspruch ihn nicht kostenlos macht.
- Kein brauchbarer Schlüssel oder Ablehnung des Schlüssels (401, 403): die Notiz wartet eine Stunde mit `EXTRACT_DISABLED` (kein Fehlversuch); je Periode entsteht ein Ereignis für `system:manage`.
- Modell lehnt ab (`refusal`): sofort `FAILED` mit `REFUSED`. Abgeschnitten, ungültige Antwort oder `BAD_REQUEST`: Fehlversuch mit `EXTRACT_INVALID`. Netz, Zeitüberschreitung, 408, 409, 429, 5xx: Fehlversuch mit `EXTRACT_RETRY`, nach dem dritten `EXTRACT_FAILED`.
- Abbruch beim Herunterfahren: nicht gezählt, die Notiz wird sofort wieder frei (`ABORTED`).
- Serverseitiger Ausweichweg: für Sonnet und Opus setzt der Adapter bei `VOICE_SERVER_FALLBACK=default` die Beta-Kennung `server-side-fallback-2026-07-01` und `fallbacks: "default"`; eine abgelehnte Anfrage läuft dann bei Anthropic auf dem empfohlenen Modell neu. Gebucht wird das tatsächlich antwortende Modell (Annahme 115). Haiku hat keinen Ausweichweg.
**Messung.** `npm run eval:voice` (Gold-Menge `test/fixtures/voice/gold/g01` bis `g30`, Schwellen: Genauigkeit ≥ 98 %, Trefferquote ≥ 85 %) läuft ohne Zusatz offline gegen eine Attrappe; `-- --live --yes` ruft Claude auf und kostet etwa 0,21 $ für 30 Fälle (Sonnet). Es läuft nie in der CI.
### 14.5 Ereignisse und Meldungen
`VoiceCaptureReady` und `VoiceCaptureFailed` (Outbox, in derselben Transaktion wie der Zustandswechsel; Nutzlast Ids und Code). Handler (`voice.handlers.ts`, über `VoiceHandlersModule` im Worker): `voice.notify-ready` legt dem Besitzer eine Meldung `HINWEIS` der Kategorie `SUGGESTION` an („Sprachnotiz für {Volk} ist fertig“, Link `/inspections/new?colonyId=…&voice=<id>`, einmal je Notiz), `voice.notify-failed` eine `WARNUNG` mit dem deutschen Hinweistext des Codes und Link `/voice`, nur wenn die Notiz `FAILED` ist. `EXTRACT_DISABLED` erzeugt zusätzlich einen Hinweis ohne Nutzer-, Volk- und Notizbezug an `system:manage`. Beide Meldungen verlangen `voice-captures:use` beim Empfänger. Ein Fehlschlag des Jobs meldet wie bei allen Jobs an `system:manage`; die Systemkarte zeigt Zähler je Status und Fehlercode der letzten 7 Tage sowie Tokens je Modell und die daraus geschätzten Kosten (`lib/voice/prices.ts`, dieselbe Preistabelle wie `src/voice/eval/voice-prices.ts`; Annahme 114).
### 14.6 Frontend
- **Aufnahme.** `components/voice/voice-recorder.tsx` mit `lib/voice/recorder.ts`: höchstens 180 s (`MAX_RECORDING_SEC`, Warnung ab 150 s), Bildschirm bleibt wach; nach einer Mikrofonverweigerung hilft `microphone-help.tsx`. Der Server nimmt bis 240 s an, damit eine Messabweichung die Notiz nicht verwirft. Ein Hinweis zur Aufnahme erscheint einmal je Gerät (`recording-notice-dialog.tsx`). Endet die Aufnahme von selbst, geht sie sofort in die Geräte-Warteschlange (Annahme 112).
- **Geräte-Warteschlange.** IndexedDB, Version 6, Store `pending-voice` (Index `ownerUserId`, `lib/voice/pending-voice.ts`, Typen in `lib/offline/db.ts`). Der Upload hängt am Synclauf (`uploadPendingVoice` nach Datensätzen und Fotos): ältester zuerst, mit einem festen `uploadIdempotencyKey` je Notiz, damit ein verlorener Antwortweg nichts doppelt anlegt. 2xx löscht den lokalen Ton sofort; eine endgültige Ablehnung (4xx) bleibt mit Fehler an der Notiz und wartet auf den Nutzer; Netzfehler, 5xx, 408 und 429 beenden den Lauf, `TOO_MANY_OPEN` sperrt den Eintrag, bis Platz ist. Notizen eines anderen Kontos werden gezählt, nie gesendet. Beim Abmelden löscht `clearQueueOf` die Sprachnotizen des Kontos; die Abmeldefrage nennt vorher die Zahl der noch nicht gesendeten Notizen.
- **Seiten.** `/voice` (Liste: Notizen vom Server und vom Gerät, Verwerfen, Wiederholen), `/voice/new` (aufnehmen, Volk und Tag wählen), Einstiege per `VoiceEntryLink` in Schnell-Anlegen, Navigation, Volksseite und Durchsicht; sie erscheinen nur, wenn der Schalter `on` oder `unknown` (offline) ist (`useVoiceRecordingSwitch`: `loading`, `on`, `off`, `unknown`). `/settings/voice` („Sprache & Datenschutz“): Info- und Datenschutztext, Einwilligung zur Browser-Cloud-Erkennung (aus), „Alle löschen“ für Server und Gerät.
- **Durchsicht.** `inspections/new?voice=<id>` zeigt oben die Notiz (`VoiceReviewBox`, `useVoiceReview`: fragt alle 5 s, höchstens 10 min) und je Feld einen Vorschlag mit Zitat (`VoiceFieldRow`, „Übernehmen“, „Alle übernehmen“, Ignorieren). Nichts wird ohne Tipp übernommen; die Abschrift lässt sich einsehen und in die Zwischenablage kopieren, auch wenn die Auswertung aus ist (`TRANSCRIBED`). Beim Speichern geht `voiceCaptureId` mit.
- **Weg bei Konflikt.** 409 `VOICE_CAPTURE_GONE` beim Speichern: `createInspection` versucht es genau einmal ohne `voiceCaptureId` und mit demselben `Idempotency-Key` erneut (der Interceptor gibt den Schlüssel bei Handlerfehler frei und prüft den Rumpf nicht; ein neuer Schlüssel könnte doppelt anlegen). 409 `VOICE_CAPTURE_COLONY_MISMATCH` wird nie wiederholt; in der Warteschlange erscheint „Ohne Sprachnotiz senden“ (`retryInspectionWithoutVoice`), und der Nutzer entscheidet.
- **Textweg.** „Ganze Durchsicht auswerten“ (`EvaluateDictationButton`, unter den Textfeldern) sendet den noch in den vier Freitextfeldern stehenden diktierten Text (20 bis 4000 Zeichen, so normalisiert wie auf dem Server) an `POST /voice-captures/text` mit einem `Idempotency-Key` je Inhalt. Daneben steht „Der Text geht zur Auswertung an Anthropic.“. Offline ist der Knopf gesperrt (nichts wird eingereiht); mit `?voice=` fehlt er; die Antwort kommt als Sprachnotiz oben im Formular, und nach 2 min Warten ohne Ergebnis verweist ein Hinweis auf die Meldung.
- **Service-Worker.** `public/sw-routes.js` cacht `/api/v1/voice-captures` nie (Abschrift und Vorschläge sind Inhalt) und hält nur die Hüllen `/voice` und `/voice/new` vor.
### 14.7 Rollout
Reihenfolge, jeder Schritt mit Freigabe:
1. **Schlüssel vor dem Image.** `deploy/k8s/scripts/ensure-secrets.sh` legt `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret` an (48 Zufallsbytes, Base64URL); er muss existieren und gesichert sein, bevor das Backend-Image mit T7 startet, denn in Produktion startet sonst keine Rolle (Annahme 22). Dann Image mit Migration. Die Auswertung bleibt ohne Anthropic-Schlüssel aus (`EXTRACT_DISABLED`).
2. **Whisper-Image.** `.gitea/workflows/whisper-image.yml` baut es; der Digest ersetzt den Platzhalter im Overlay `deploy/k8s/overlays/encry/kustomization.yaml` (Eintrag `apicore-whisper`).
3. **Komponente `voice`** (im Overlay eingebunden, erst nach Schritt 1 und 2 anwenden): CronJob und Worker-Patch. Erster Lauf von Hand (`kubectl create job --from=cronjob/apicore-job-voice-transcribe`); das Log darf keinen Inhalt enthalten.
4. **Anthropic.** Der Inhaber legt das Secret `apicore-anthropic` (Schlüssel `ANTHROPIC_API_KEY`) an und verschiebt den Schlüssel aus `apicore-backend-secret`, wo er über `envFrom` auch API- und Job-Pods erreichte; er setzt in der Anthropic-Konsole ein Ausgabenlimit (Vorschlag 10 $ im Monat). Mit 60 Aufrufen am Tag (`VOICE_DAILY_EXTRACTIONS`) bleibt der Verbrauch dahinter.
5. **Messung (Task 24)** auf dem Zielknoten: Speicherspitze des Whisper-Containers gegen 1280Mi (inklusive der Speicher-Volumes), Zeit und CPU-Drosselung, Trefferquote der Fachwörter (Ziel ≥ 90 %, sonst `medium` oder längere Fachwortliste), einmal `npm run eval:voice -- --live --yes`.
6. **Freigabe (Task 25).** `VOICE_RECORDING_ENABLED` auf `"true"` und Frontend ausrollen. Rückweg: Schalter auf `"false"` und die Komponente `voice` abschalten; offene Notizen laufen über die Fristen ab, es gehen nur flüchtige Daten verloren.
+7 -3
View File
@@ -353,8 +353,12 @@ Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Teil 1 (Daten) und
- [x] T7 L2 Backend (Tasks 4–12): Datenmodell, Schlüssel `voice`, Kern, API, Übernahme in die Durchsicht, Ereignisse, Wiederholregel, Fachwortliste
- [x] T7 L3 Abschreiben im Cluster (Tasks 11, 13): Job `voice-transcribe`, Whisper-Image (CI gebaut), CronJob-Komponente `voice`, Restrisiko als Annahme 106
- [x] T7 L5 Frontend (Tasks 17–20, 22): Aufnahme, Warteschlange, Abmelden, `/voice`, `/voice/new`, Sprachnotiz in der Durchsicht, Info-Seite, Systemkarte; Browserprüfung Aufnahme/Liste bestanden
- [ ] T7 Browserprüfung Durchsicht mit Sprachnotiz und Einstellungen (läuft)
- [ ] T7 L4 Auswertung mit Claude (Tasks 14–16, 21) wartet auf Spec-Frage 3 (Drittland); Tasks 23 (e2e), 24 (Messung) und 25 (Rollout) danach
- [x] T7 Browserprüfung Aufnahme, Liste, Durchsicht mit Sprachnotiz und Einstellungen (1280/390 px, Tastatur, offline, reduced motion): 0 kritisch, 0 wichtig offen (2026-10-16)
- [x] T7 L4 Auswertung mit Claude, Tasks 14–16 (Adapter, Worker-Schleife `voice-extractions`, Prüfung, Tagesbudget über `VoiceExtractionCall`, Gold-Menge und `npm run eval:voice`); Spec-Frage 3 gilt mit dem angelegten Schlüssel als beantwortet (Annahme 116)
- [x] T7 Task 21 Textweg „Ganze Durchsicht auswerten“ (f6e1f34b)
- [ ] T7 Task 23 e2e des ganzen Ablaufs (in Arbeit)
- [ ] T7 Task 24 Messung auf dem Zielknoten (RAM gegen 1280Mi, Fachwort-Trefferquote, Zeit) und Task 25 Rollout (Reihenfolge ARCHITECTURE §14.7): brauchen den Inhaber
- [ ] Inhaber (T7): `ANTHROPIC_API_KEY` aus `apicore-backend-secret` in das Secret `apicore-anthropic` verschieben (sonst erreicht er auch API- und Job-Pods), Ausgabenlimit in der Anthropic-Konsole setzen, einmal `npm run eval:voice -- --live --yes` laufen lassen (rund 0,21 $), Whisper-Digest ins Overlay eintragen und `VOICE_ENCRYPTION_KEY` sichern
- [ ] T7 mit echtem Mikrofon auf iPhone (Safari) und in Chrome prüfen (Inhaber)
- [ ] Vor dem nächsten Backend-Rollout: `VOICE_ENCRYPTION_KEY` per `ensure-secrets.sh` anlegen und sichern (#28)
- [ ] Next.js auf aktuelle Patch-Version heben (npm audit: kritische Lücke nur für Windows-Server, Produktion läuft unter Linux)
@@ -371,7 +375,7 @@ Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native B
- [x] NFC-Tags (Web-NFC-API, nur Chrome/Android; kodiert denselben Deep-Link wie der QR-Code; kein Fallback für andere Browser möglich)
- [x] ~~Native Smartphone-App~~ – nicht umsetzbar in diesem Kontext: erfordert eigene native Codebasis, App-Store-Vertrieb und Build-Pipeline ausserhalb einer Web-Session
- [x] ~~KI-Auswertung von Völkerdaten, automatische Anomalieerkennung~~ – kein eigenes ML-Modell; regelbasierte Auswertung über T4 (Vorschläge, Autopilot). Fotoanalyse der Beute als „Beute Teil 3“ (#24) mit eigener Spec
- [ ] Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht – wird mit T7 „Freihand“ gebaut (Whisper im Cluster, Auswertung mit Claude nach Inhaber-Freigabe; Stand im Abschnitt Beute/T7 und #27)
- [ ] Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht – wird mit T7 „Freihand“ gebaut (Whisper im Cluster, Auswertung mit Claude; Stand und Rollout in ARCHITECTURE §14 und #27)
- [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware
## ERP-Anbindung