From f6e1f34baa28659c8f2e524aad3e44ad00326bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Fri, 16 Oct 2026 16:06:00 +0200 Subject: [PATCH] feat: Ganze Durchsicht aus Diktat auswerten (T7 L4 Task 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Knopf „Ganze Durchsicht auswerten“ unter den Freitextfeldern, sobald diktiert wurde: sendet nur unveränderte Diktat-Teile, die noch in den Feldern stehen, mit Hinweis auf die Auswertung bei Anthropic. Länge und Text werden genau wie im Backend normalisiert (NFKC, Steuer- und Formatzeichen, Codepoints) und so gesendet; Idempotency-Key je Inhalt, nach endgültigem 4xx neu. Ergebnis in der Sprachnotiz-Box der Durchsicht, Vorschläge nur für leere Felder, nichts ohne Tipp; offline und bei ausgeschalteter Auswertung mit Hinweis statt Knopf. Gates: Frontend lint, typecheck, 3203 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../inspections/new/page-voice-text.spec.tsx | 281 ++++++++++++++++++ .../src/app/(app)/inspections/new/page.tsx | 47 ++- .../(app)/inspections/new/use-voice-review.ts | 59 +++- .../(app)/inspections/new/voice-review.tsx | 24 +- .../voice/evaluate-dictation-button.spec.tsx | 221 ++++++++++++++ .../voice/evaluate-dictation-button.tsx | 158 ++++++++++ .../src/lib/voice/dictated-text.spec.ts | 109 +++++++ .../src/lib/voice/dictated-text.ts | 58 ++++ 8 files changed, 944 insertions(+), 13 deletions(-) create mode 100644 ApiCore-Frontend/src/app/(app)/inspections/new/page-voice-text.spec.tsx create mode 100644 ApiCore-Frontend/src/components/voice/evaluate-dictation-button.spec.tsx create mode 100644 ApiCore-Frontend/src/components/voice/evaluate-dictation-button.tsx create mode 100644 ApiCore-Frontend/src/lib/voice/dictated-text.spec.ts create mode 100644 ApiCore-Frontend/src/lib/voice/dictated-text.ts diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page-voice-text.spec.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page-voice-text.spec.tsx new file mode 100644 index 0000000..e755a40 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page-voice-text.spec.tsx @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import NewInspectionPage from "./page"; +import { fetchColonies } from "@/lib/colonies/api"; +import { createInspection, fetchColonyInspections } from "@/lib/inspections/api"; +import { fetchApiaryWeather } from "@/lib/weather/api"; +import { + type VoiceCaptureDetail, + discardVoiceCapture, + fetchVoiceCapture, + fetchVoiceConfig, + submitVoiceText, +} from "@/lib/voice/api"; +import { syncPendingEntries } from "@/lib/offline/sync"; + +// „Ganze Durchsicht auswerten“ (Task 21): the dictated text is evaluated and the +// answer shows in the voice note box above the form. + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(search), +})); +let search = "colonyId=colony-1"; +vi.mock("@/lib/auth/auth-context", () => ({ + useCurrentUserId: () => "user-1", + useAuth: () => ({ hasPermission: () => true }), +})); +vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() })); +vi.mock("@/lib/inspections/api", () => ({ + createInspection: vi.fn(), + fetchColonyInspections: vi.fn(), +})); +vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() })); +vi.mock("@/lib/voice/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchVoiceCapture: vi.fn(), + discardVoiceCapture: vi.fn(), + fetchVoiceConfig: vi.fn(), + submitVoiceText: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); +vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null })); +vi.mock("@/lib/hive/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchHive: vi.fn().mockRejectedValue(new Error("no hive")), + fetchHiveSystems: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); +vi.mock("@/lib/photos/store", () => ({ + PHOTOS_PER_INSPECTION_MAX: 5, + listSessionPhotos: vi.fn().mockResolvedValue([]), + addDraftPhoto: vi.fn(), + removePhoto: vi.fn(), + handOverSessionPhotos: vi.fn().mockResolvedValue(0), + queueInspectionWithPhotos: vi.fn().mockResolvedValue(0), + moveDraftPhotos: vi.fn(), + discardSessionPhotos: vi.fn().mockResolvedValue(undefined), +})); +// The browser engine is not the subject: a button per field hands over what was "said". +const SAID = "Varroa kaum zu sehen, Futter niedrig, Volk sehr sanft"; +vi.mock("@/components/voice/dictation-button", () => ({ + DictationRow: ({ + children, + fieldLabel, + onAppend, + }: { + children: ReactNode; + fieldLabel: string; + onAppend: (text: string) => void; + }) => ( +
+ {children} + +
+ ), +})); + +const TWO_MINUTES = 120_000; +const FIVE_SECONDS = 5_000; + +function textNote(extra: Partial = {}): VoiceCaptureDetail { + return { + id: "txt-1", + colonyId: "colony-1", + colonyName: "V-01", + status: "READY", + source: "TEXT", + createdAt: "2026-10-16T08:00:00.000Z", + lastErrorCode: null, + contentExpiresAt: "2026-10-23T08:00:00.000Z", + inspectionDate: "2026-10-16", + transcript: SAID, + suggestions: [ + { field: "gentleness", value: 5, quote: "Volk sehr sanft", corrected: false, truncated: false }, + { field: "colonyStrength", value: 20, quote: "stark", corrected: false, truncated: false }, + ], + unclear: [], + dropped: 0, + ...extra, + }; +} + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +const DICTATE_NOTES = { name: "Diktat Notizen" }; +const EVALUATE = { name: /Ganze Durchsicht auswerten/ }; +const row = (fieldLabel: string) => + screen.queryByRole("group", { name: `Vorschlag aus Sprache: ${fieldLabel}` }); + +type User = ReturnType; + +/** The free-text fields sit in the folded section „Weitere Angaben“. */ +async function openMore(user: User) { + await user.click(await screen.findByRole("button", { name: /Weitere Angaben/ })); +} + +async function dictateAndSend(user: User) { + await openMore(user); + await user.click(await screen.findByRole("button", DICTATE_NOTES)); + await user.click(await screen.findByRole("button", EVALUATE)); +} + +describe("inspection form: Ganze Durchsicht auswerten", () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + search = "colonyId=colony-1"; + Object.defineProperty(window.navigator, "onLine", { value: true, configurable: true }); + vi.mocked(fetchColonies).mockResolvedValue([ + { id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" }, + ] as never); + vi.mocked(createInspection).mockReset().mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never); + vi.mocked(fetchColonyInspections).mockResolvedValue([]); + vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] }); + vi.mocked(fetchVoiceCapture).mockReset().mockResolvedValue(textNote()); + vi.mocked(discardVoiceCapture).mockReset().mockResolvedValue(undefined); + vi.mocked(submitVoiceText).mockReset().mockResolvedValue({ id: "txt-1" }); + vi.mocked(fetchVoiceConfig).mockResolvedValue({ + recordingEnabled: true, + extractionEnabled: true, + model: "m", + retention: { audioHours: 24, contentDays: 7, rowDays: 90 }, + }); + vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("offers the button only after something was dictated", async () => { + const user = userEvent.setup(); + renderPage(); + await openMore(user); + await screen.findByRole("button", DICTATE_NOTES); + expect(screen.queryByRole("button", EVALUATE)).toBeNull(); + + await user.click(screen.getByRole("button", DICTATE_NOTES)); + + expect(await screen.findByRole("button", EVALUATE)).toBeEnabled(); + expect(screen.getByText(/Der Text geht zur Auswertung an Anthropic/)).toBeInTheDocument(); + }); + + it("is not offered when the form was opened from a voice note", async () => { + search = "colonyId=colony-1&voice=cap-1"; + vi.mocked(fetchVoiceCapture).mockResolvedValue(textNote({ id: "cap-1", source: "AUDIO" })); + const user = userEvent.setup(); + renderPage(); + await openMore(user); + await user.click(await screen.findByRole("button", DICTATE_NOTES)); + expect(screen.queryByRole("button", EVALUATE)).toBeNull(); + }); + + it("sends only the dictated text still in a field, with colony and day", async () => { + const user = userEvent.setup(); + renderPage(); + await dictateAndSend(user); + + await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(1)); + const [input, key] = vi.mocked(submitVoiceText).mock.calls[0]; + expect(input).toMatchObject({ colonyId: "colony-1", text: SAID }); + expect(input.inspectionDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(key).toBeTruthy(); + }); + + it("does not send text the beekeeper deleted again", async () => { + const user = userEvent.setup(); + renderPage(); + await openMore(user); + await user.click(await screen.findByRole("button", DICTATE_NOTES)); + await user.clear(screen.getByLabelText(/^Notizen/)); + + expect(screen.queryByRole("button", EVALUATE)).toBeNull(); + }); + + it("shows the answer in the voice box with suggestions only for empty fields and moves the focus", async () => { + const user = userEvent.setup(); + renderPage(); + await user.type(await screen.findByLabelText(/^Volksstärke/), "12"); + await dictateAndSend(user); + + const heading = await screen.findByRole("heading", { name: /^Sprachnotiz vom 16\.10\./ }); + expect(heading).toBeInTheDocument(); + await waitFor(() => expect(document.getElementById("voice-capture-box")).toHaveFocus()); + expect(row("Sanftmut")).not.toBeNull(); + expect(row("Volksstärke")).toBeNull(); + expect(screen.getByRole("heading", { name: /1 Vorschlag$/ })).toBeInTheDocument(); + // Nothing is filled by itself. + expect(screen.getByLabelText(/^Volksstärke/)).toHaveValue("12"); + }); + + it("polls every 5 seconds until the note is ready", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + vi.mocked(fetchVoiceCapture) + .mockResolvedValueOnce(textNote({ status: "TRANSCRIBED", suggestions: null })) + .mockResolvedValue(textNote()); + renderPage(); + await dictateAndSend(user); + expect(await screen.findByText("Wird noch ausgewertet")).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(FIVE_SECONDS + 100); + }); + + expect(await screen.findByRole("heading", { name: /2 Vorschläge$/ })).toBeInTheDocument(); + }); + + it("stops polling after two minutes and points to the notification", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + vi.mocked(fetchVoiceCapture).mockResolvedValue(textNote({ status: "EXTRACTING", suggestions: null })); + renderPage(); + await dictateAndSend(user); + await screen.findByText("Wird noch ausgewertet"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(TWO_MINUTES + 100); + }); + const callsAtGiveUp = vi.mocked(fetchVoiceCapture).mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(FIVE_SECONDS * 4); + }); + + expect(vi.mocked(fetchVoiceCapture).mock.calls.length).toBe(callsAtGiveUp); + expect(screen.getByText(/Du bekommst eine Meldung/)).toBeInTheDocument(); + }); + + it("saves the inspection with the text note's id", async () => { + const user = userEvent.setup(); + renderPage(); + await dictateAndSend(user); + await screen.findByRole("heading", { name: /^Sprachnotiz vom/ }); + + await user.click(screen.getByRole("button", { name: "Durchsicht speichern" })); + + await waitFor(() => expect(createInspection).toHaveBeenCalled()); + expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ voiceCaptureId: "txt-1" }); + }); + + it("puts the box above the form and nothing in the fields on its own", async () => { + const user = userEvent.setup(); + renderPage(); + await dictateAndSend(user); + const box = await screen.findByRole("region", { name: /^Sprachnotiz vom/ }); + expect(within(box).getByRole("button", { name: /Alle übernehmen/ })).toBeInTheDocument(); + expect(screen.getByLabelText(/^Notizen/)).toHaveValue(SAID); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index 74fb0e8..b11c744 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -21,7 +21,7 @@ import { import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection"; import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types"; import { hiveKeys } from "@/lib/hive/api"; -import { voiceKeys } from "@/lib/voice/api"; +import { discardVoiceCapture, voiceKeys } from "@/lib/voice/api"; import { INSPECTION_SCALE_DIRECTIONS, INSPECTION_SCALE_LABELS } from "@/lib/inspections/scales"; import type { HiveOp } from "@/lib/hive/types"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; @@ -45,8 +45,10 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { DictationRow } from "@/components/voice/dictation-button"; +import { EvaluateDictationButton } from "@/components/voice/evaluate-dictation-button"; import { VoiceEntryLink } from "@/components/voice/voice-entry-link"; import { appendDictation } from "@/lib/voice/append-text"; +import { emptyVoiceFields, joinDictatedText, stillPresentChunks } from "@/lib/voice/dictated-text"; import { FormBackLink } from "@/components/form/form-back-link"; import { RoundNextPanel } from "@/components/rounds/round-next-panel"; import { PageHeader } from "@/components/page-header"; @@ -76,7 +78,12 @@ import { import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change"; import { suggestAfterInspection } from "./hive-save-follow-ups"; import { InspectionHiveSection, useInspectionHive } from "./hive-section"; -import { isGoneVoiceStatus, isVoiceColonyMismatch, useVoiceReview } from "./use-voice-review"; +import { + isGoneVoiceStatus, + isVoiceColonyMismatch, + useVoiceReview, + type TextNote, +} from "./use-voice-review"; import { VoiceFieldRow, VoiceReviewBox } from "./voice-review"; /** Free-text fields that take dictation. */ @@ -248,8 +255,12 @@ function InspectionCaptureForm({ const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId); const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog); // A tap on „Übernehmen“ writes like any tap; observations are never ticked for the Volkskarte. + // What was dictated into the free-text fields, and the note „Ganze Durchsicht auswerten“ made from it. + const [dictated, setDictated] = useState([]); + const [textNote, setTextNote] = useState(null); const voice = useVoiceReview({ voiceId, + textNote, initialColonyId, values, apply: (patch) => { @@ -340,10 +351,33 @@ function InspectionCaptureForm({ /** Dictation is added to the end of a free-text field and marks the form as changed. */ function appendDictated(key: DictatedField, text: string) { + if (text.trim() !== "") setDictated((current) => [...current, text.trim()]); update((current) => ({ ...current, [key]: appendDictation(current[key], text) })); setDirty(true); } + /** The dictated text that is still written in a field: only that leaves for the evaluation. */ + const evaluationText = joinDictatedText( + stillPresentChunks(dictated, [ + values.varroaIssues, + values.healthIssues, + values.measures, + values.notes, + ]), + ); + + /** The evaluation of the dictated text started: its note shows in the box above the form. */ + function startedTextNote({ id }: { id: string }) { + const earlier = textNote?.id; + setTextNote({ id, emptyFields: emptyVoiceFields(values) }); + if (earlier && earlier !== id) { + // The earlier text note is replaced; the server would drop it after 7 days anyway, so a + // failure here is not worth a message to the beekeeper. + discardVoiceCapture(earlier).catch(() => undefined); + } + void queryClient.invalidateQueries({ queryKey: voiceKeys.all, refetchType: "none" }); + } + /** How many photos the form's photo section shows (none while it is hidden). */ function shownPhotoCount(photoSessionId: string): number { return ( @@ -1008,6 +1042,15 @@ function InspectionCaptureForm({ )} + {voiceId === null ? ( + + ) : null} ; +} + export interface UseVoiceReviewOptions { /** `?voice=`; null when the form was not opened from a voice note. */ voiceId: string | null; + /** The evaluation of the dictated text; used while `voiceId` is null. */ + textNote?: TextNote | null; /** The colony in the address: the best guess for the note's colony until it has loaded. */ initialColonyId: string; values: InspectionFormValues; @@ -97,8 +108,18 @@ function focusFieldOf(field: string): void { * a tap (`apply`). The id goes to the server only while the note is still * open and belongs to the form's colony. */ -export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseVoiceReviewOptions) { +export function useVoiceReview({ + voiceId: urlVoiceId, + textNote = null, + initialColonyId, + values, + apply, +}: UseVoiceReviewOptions) { const queryClient = useQueryClient(); + const voiceId = urlVoiceId ?? textNote?.id ?? null; + const emptyFields = urlVoiceId === null && textNote ? textNote.emptyFields : null; + const [gaveUpFor, setGaveUpFor] = useState(null); + const textWaitEnds = emptyFields !== null; const [settled, setSettled] = useState>(new Map()); const [leftOut, setLeftOut] = useState(false); @@ -112,6 +133,14 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV const extractionEnabled = config.data?.extractionEnabled ?? true; const [startedAt] = useState(() => Date.now()); + // A text note is evaluated within moments; if it is not, the notification tells when it is. + useEffect(() => { + if (!textWaitEnds || voiceId === null) return; + const timer = window.setTimeout(() => setGaveUpFor(voiceId), TEXT_POLL_GIVE_UP_MS); + return () => window.clearTimeout(timer); + }, [textWaitEnds, voiceId]); + const waitGaveUp = textWaitEnds && gaveUpFor === voiceId; + const query = useQuery({ queryKey: voiceKeys.detail(voiceId ?? ""), queryFn: () => fetchVoiceCapture(voiceId ?? ""), @@ -120,11 +149,20 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV refetchInterval: (state) => state.state.data && isVoiceInProgress(state.state.data.status, extractionEnabled) && + !waitGaveUp && Date.now() - startedAt < POLL_GIVE_UP_MS ? POLL_MS : false, }); - const capture: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data; + const loaded: VoiceCaptureDetail | undefined = leftOut ? undefined : query.data; + // Only the fields that were empty when the text was sent: nothing already answered is replaced. + const capture: VoiceCaptureDetail | undefined = + loaded && emptyFields && loaded.suggestions + ? { + ...loaded, + suggestions: loaded.suggestions.filter((suggestion) => emptyFields.has(suggestion.field)), + } + : loaded; const notFound = query.error instanceof ApiError && query.error.status === NOT_FOUND; const isGone = capture ? isGoneVoiceStatus(capture.status) : notFound; const noteColonyId = capture?.colonyId ?? initialColonyId; @@ -236,6 +274,17 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV }); const sendsId = voiceId !== null && !leftOut && !isGone && sameColony; + const inProgress = capture ? isVoiceInProgress(capture.status, extractionEnabled) : false; + + // The answer to the dictated text arrives while the beekeeper works elsewhere on the form: + // the box takes the focus once, so a screen reader announces it. + const focusedFor = useRef(null); + const answered = textWaitEnds && capture !== undefined && !inProgress; + useEffect(() => { + if (!answered || voiceId === null || focusedFor.current === voiceId) return; + focusedFor.current = voiceId; + document.getElementById(VOICE_CAPTURE_BOX_ID)?.focus(); + }, [answered, voiceId]); return { voiceId, @@ -248,6 +297,10 @@ export function useVoiceReview({ voiceId, initialColonyId, values, apply }: UseV /** The id to send with the inspection, if the note should be tied to it. */ voiceCaptureId: sendsId ? voiceId : undefined, openCount: open.length, + /** The note is still being transcribed or evaluated. */ + inProgress, + /** Evaluating, and the wait for it has ended: the notification will tell when it is done. */ + waitGaveUp: waitGaveUp && inProgress, rowFor, takeOverAll, discard: () => discard.mutate(), diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx index 1342956..8259f12 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/voice-review.tsx @@ -45,14 +45,22 @@ export function VoiceReviewBox({ review }: { review: VoiceReview }) { ); } return ( - +
+ + {review.waitGaveUp ? ( +

+ Die Auswertung dauert länger. Du bekommst eine Meldung, sobald sie fertig ist, und kannst + die Durchsicht so lange weiter ausfüllen. +

+ ) : null} +
); } diff --git a/ApiCore-Frontend/src/components/voice/evaluate-dictation-button.spec.tsx b/ApiCore-Frontend/src/components/voice/evaluate-dictation-button.spec.tsx new file mode 100644 index 0000000..aad0b58 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/evaluate-dictation-button.spec.tsx @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { EvaluateDictationButton } from "./evaluate-dictation-button"; +import { ApiError } from "@/lib/auth/api-client"; +import { fetchVoiceConfig, submitVoiceText } from "@/lib/voice/api"; +import { reportRequestOutcome } from "@/lib/offline/connectivity"; + +let allowed = true; +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ hasPermission: () => allowed }), +})); +vi.mock("@/lib/voice/api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchVoiceConfig: vi.fn(), + submitVoiceText: vi.fn(), +})); + +const TEXT = "Königin gesehen, Futter niedrig, Volk sehr sanft."; +const BUTTON = { name: /Ganze Durchsicht auswerten/ }; +const CONFIG = { + recordingEnabled: true, + extractionEnabled: true, + model: "m", + retention: { audioHours: 24, contentDays: 7, rowDays: 90 }, +}; + +type Props = React.ComponentProps; + +function ui(props: Partial = {}) { + return ( + + ); +} + +function renderButton(props: Partial = {}) { + const onSubmitted = vi.fn(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render( + {ui({ onSubmitted, ...props })}, + ); + return { + onSubmitted, + rerenderWith: (next: Partial) => + view.rerender( + {ui({ onSubmitted, ...next })}, + ), + ...view, + }; +} + +describe("EvaluateDictationButton", () => { + beforeEach(() => { + allowed = true; + reportRequestOutcome("reached-server"); + Object.defineProperty(window.navigator, "onLine", { value: true, configurable: true }); + vi.mocked(fetchVoiceConfig).mockReset().mockResolvedValue(CONFIG); + vi.mocked(submitVoiceText).mockReset().mockResolvedValue({ id: "cap-9" }); + }); + + it("is hidden without dictation", () => { + const { container } = renderButton({ text: "" }); + expect(container).toBeEmptyDOMElement(); + }); + + it("is hidden without the right to use voice notes", () => { + allowed = false; + const { container } = renderButton(); + expect(container).toBeEmptyDOMElement(); + expect(fetchVoiceConfig).not.toHaveBeenCalled(); + }); + + it("says in one line that the text goes to Anthropic", async () => { + renderButton(); + const button = await screen.findByRole("button", BUTTON); + expect(button).toHaveAccessibleDescription(/Der Text geht zur Auswertung an Anthropic/); + expect(button.className).toMatch(/min-h-14/); + }); + + it("is disabled offline and says why", async () => { + Object.defineProperty(window.navigator, "onLine", { value: false, configurable: true }); + renderButton(); + expect(await screen.findByRole("button", BUTTON)).toBeDisabled(); + expect(screen.getByText(/braucht eine Verbindung/)).toBeInTheDocument(); + }); + + it("explains instead of offering when the evaluation is switched off", async () => { + vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, extractionEnabled: false }); + renderButton(); + expect(await screen.findByText(/Auswertung ist gerade ausgeschaltet/)).toBeInTheDocument(); + expect(screen.queryByRole("button", BUTTON)).toBeNull(); + }); + + it("explains when voice notes are switched off", async () => { + vi.mocked(fetchVoiceConfig).mockResolvedValue({ ...CONFIG, recordingEnabled: false }); + renderButton(); + expect(await screen.findByText(/Sprachnotizen sind gerade ausgeschaltet/)).toBeInTheDocument(); + expect(screen.queryByRole("button", BUTTON)).toBeNull(); + }); + + it("keeps the button when the switch cannot be asked", async () => { + vi.mocked(fetchVoiceConfig).mockRejectedValue(new TypeError("Failed to fetch")); + renderButton(); + expect(await screen.findByRole("button", BUTTON)).toBeEnabled(); + }); + + it("blocks a text that is too short or too long with a hint", async () => { + const { rerenderWith } = renderButton({ text: "zu kurz" }); + expect(await screen.findByRole("button", BUTTON)).toBeDisabled(); + expect(screen.getByText(/mindestens 20 Zeichen/)).toBeInTheDocument(); + rerenderWith({ text: "wort ".repeat(900).trim() }); + expect(await screen.findByText(/höchstens 4000 Zeichen/)).toBeInTheDocument(); + expect(screen.getByRole("button", BUTTON)).toBeDisabled(); + }); + + it("sends the dictated text once with an idempotency key and hands over the id", async () => { + const user = userEvent.setup(); + const { onSubmitted } = renderButton(); + await user.click(await screen.findByRole("button", BUTTON)); + await waitFor(() => expect(onSubmitted).toHaveBeenCalledWith({ id: "cap-9" })); + expect(submitVoiceText).toHaveBeenCalledTimes(1); + const [input, key] = vi.mocked(submitVoiceText).mock.calls[0]; + expect(input).toEqual({ colonyId: "colony-1", inspectionDate: "2026-10-16", text: TEXT }); + expect(key).toMatch(/\S{8,}/); + }); + + it("repeats a lost send with the same key and a new send with a new one", async () => { + const user = userEvent.setup(); + vi.mocked(submitVoiceText).mockRejectedValueOnce(new TypeError("Failed to fetch")); + renderButton(); + await user.click(await screen.findByRole("button", BUTTON)); + expect(await screen.findByRole("alert")).toHaveTextContent(/noch einmal/); + await user.click(screen.getByRole("button", BUTTON)); + await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(2)); + const keys = vi.mocked(submitVoiceText).mock.calls.map((call) => call[1]); + expect(keys[0]).toBe(keys[1]); + await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled()); + await user.click(screen.getByRole("button", BUTTON)); + await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(3)); + expect(vi.mocked(submitVoiceText).mock.calls[2][1]).not.toBe(keys[1]); + }); + + it("shows progress in a live region while sending", async () => { + const user = userEvent.setup(); + let release: (value: { id: string }) => void = () => undefined; + vi.mocked(submitVoiceText).mockReturnValue(new Promise((resolve) => (release = resolve))); + renderButton(); + await user.click(await screen.findByRole("button", BUTTON)); + expect(await screen.findByRole("status")).toHaveTextContent(/Wird gesendet/); + expect(screen.getByRole("button", BUTTON)).toBeDisabled(); + release({ id: "cap-9" }); + await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled()); + }); + + it("asks for colony and day first when one is missing", async () => { + renderButton({ colonyId: "" }); + expect(await screen.findByRole("button", BUTTON)).toBeDisabled(); + expect(screen.getByText(/Volk und Datum/)).toBeInTheDocument(); + }); + + it("checks and sends the text the way the server counts it", async () => { + const user = userEvent.setup(); + const padded = "​zu kurz​".repeat(2); + const { rerenderWith } = renderButton({ text: padded }); + expect(await screen.findByRole("button", BUTTON)).toBeDisabled(); + const real = "Königin​ gesehen, Futter niedrig"; + rerenderWith({ text: real }); + await user.click(screen.getByRole("button", BUTTON)); + await waitFor(() => expect(submitVoiceText).toHaveBeenCalled()); + expect(vi.mocked(submitVoiceText).mock.calls[0][0].text).toBe("Königin gesehen, Futter niedrig"); + }); + + it("says that only unchanged dictation is sent", async () => { + renderButton(); + expect(await screen.findByText(/Nur unveränderte Diktat-Teile/)).toBeInTheDocument(); + }); + + it("starts a new send after a definitive refusal, keeps the key after a network error or 5xx", async () => { + const user = userEvent.setup(); + vi.mocked(submitVoiceText) + .mockRejectedValueOnce(new ApiError(429, "x", { code: "TOO_MANY_OPEN" })) + .mockRejectedValueOnce(new ApiError(503, "x", {})) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue({ id: "cap-9" }); + renderButton(); + for (let i = 0; i < 4; i++) { + await user.click(await screen.findByRole("button", BUTTON)); + await waitFor(() => expect(submitVoiceText).toHaveBeenCalledTimes(i + 1)); + await waitFor(() => expect(screen.getByRole("button", BUTTON)).toBeEnabled()); + } + const keys = vi.mocked(submitVoiceText).mock.calls.map((call) => call[1]); + expect(keys[1]).not.toBe(keys[0]); + expect(keys[2]).toBe(keys[1]); + expect(keys[3]).toBe(keys[1]); + }); + + it("is disabled while the earlier note is still being evaluated", async () => { + renderButton({ busy: true }); + expect(await screen.findByRole("button", BUTTON)).toBeDisabled(); + expect(screen.getByText(/läuft noch/)).toBeInTheDocument(); + }); + + it.each([ + [403, "VOICE_RECORDING_DISABLED", /Sprachnotizen sind gerade ausgeschaltet/], + [429, "TOO_MANY_OPEN", /Zu viele offene Sprachnotizen/], + [400, "BAD_REQUEST", /nicht gesendet werden/], + ])("names refusal %s %s", async (status, code, message) => { + const user = userEvent.setup(); + vi.mocked(submitVoiceText).mockRejectedValue(new ApiError(status, "x", { code })); + renderButton(); + await user.click(await screen.findByRole("button", BUTTON)); + expect(await screen.findByRole("alert")).toHaveTextContent(message); + }); +}); diff --git a/ApiCore-Frontend/src/components/voice/evaluate-dictation-button.tsx b/ApiCore-Frontend/src/components/voice/evaluate-dictation-button.tsx new file mode 100644 index 0000000..9278f37 --- /dev/null +++ b/ApiCore-Frontend/src/components/voice/evaluate-dictation-button.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useId, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ApiError } from "@/lib/auth/api-client"; +import { useAuth } from "@/lib/auth/auth-context"; +import { newIdempotencyKey } from "@/lib/offline/idempotency"; +import { useConnectivity } from "@/lib/offline/connectivity"; +import { submitVoiceText } from "@/lib/voice/api"; +import { + DICTATED_TEXT_MAX, + DICTATED_TEXT_MIN, + dictatedTextProblem, + normalizeForLength, +} from "@/lib/voice/dictated-text"; +import { voiceErrorText } from "@/lib/voice/status-texts"; +import { offersRecording, useVoiceConfig, useVoiceRecordingSwitch } from "@/lib/voice/use-voice-recording-switch"; + +interface EvaluateDictationButtonProps { + colonyId: string; + /** The inspection day, "YYYY-MM-DD". */ + inspectionDate: string; + /** The dictated text, pieces joined and white space collapsed; "" while nothing was dictated. */ + text: string; + /** A note made from an earlier send is still being evaluated. */ + busy?: boolean; + onSubmitted(result: { id: string }): void; +} + +const HINT_CLASS = "text-sm text-muted-foreground"; +const SEND_FAILED = "Der Text konnte nicht gesendet werden. Bitte noch einmal versuchen."; +const RECORDING_OFF = "Sprachnotizen sind gerade ausgeschaltet."; + +function refusalText(error: unknown): string { + if (error instanceof ApiError) { + const code = (error.body as { code?: unknown } | null)?.code; + if (code === "VOICE_RECORDING_DISABLED") return RECORDING_OFF; + if (code === "TOO_MANY_OPEN") return voiceErrorText("TOO_MANY_OPEN"); + if (error.status === 429) return "Zu viele Anfragen. Bitte gleich noch einmal versuchen."; + if (error.status === 400) return "Der Text konnte nicht gesendet werden. Bitte kürzen oder ändern."; + } + return SEND_FAILED; +} + +function problemText(problem: "short" | "long"): string { + return problem === "short" + ? `Zu kurz zum Auswerten: mindestens ${DICTATED_TEXT_MIN} Zeichen.` + : `Zu lang zum Auswerten: höchstens ${DICTATED_TEXT_MAX} Zeichen.`; +} + +/** + * „Ganze Durchsicht auswerten“ under the free-text fields: sends what was + * dictated to the server for evaluation. The text leaves for Anthropic, so + * the button says so right next to itself. It needs a connection (nothing is + * queued) and never fills a field: the answer comes as a voice note above the + * form, whose suggestions are taken over by tap. + */ +export function EvaluateDictationButton({ + colonyId, + inspectionDate, + text: rawText, + busy = false, + onSubmitted, +}: EvaluateDictationButtonProps) { + // Checked and sent are the same text: the server's own normalisation. + const text = normalizeForLength(rawText); + const { hasPermission } = useAuth(); + const allowed = hasPermission("voice-captures:use"); + const wanted = allowed && text !== ""; + const recordingSwitch = useVoiceRecordingSwitch(wanted); + const config = useVoiceConfig(wanted); + const online = useConnectivity() === "online"; + const noteId = useId(); + // One key per text: a repeat after a lost answer is the same send, a changed text a new one. + const pending = useRef<{ payload: string; key: string } | null>(null); + const [error, setError] = useState(null); + + const send = useMutation({ + mutationFn: () => { + const payload = JSON.stringify([colonyId, inspectionDate, text]); + if (pending.current?.payload !== payload) { + pending.current = { payload, key: newIdempotencyKey() }; + } + return submitVoiceText({ colonyId, inspectionDate, text }, pending.current.key); + }, + onSuccess: (result) => { + pending.current = null; + setError(null); + onSubmitted(result); + }, + onError: (failure) => { + // A definitive refusal ends this send; only a lost answer (network, 5xx) is repeated with its key. + if (failure instanceof ApiError && failure.status >= 400 && failure.status < 500) { + pending.current = null; + } + setError(refusalText(failure)); + }, + }); + + if (!wanted || recordingSwitch === "loading") return null; + if (!offersRecording(recordingSwitch)) { + return

{RECORDING_OFF}

; + } + if (config.data?.extractionEnabled === false) { + return ( +

+ Die Auswertung ist gerade ausgeschaltet. Bitte von Hand ausfüllen. +

+ ); + } + + const problem = dictatedTextProblem(text); + const unplaced = colonyId === "" || inspectionDate === ""; + const hint = !online + ? "Die Auswertung braucht eine Verbindung." + : unplaced + ? "Bitte zuerst Volk und Datum wählen." + : busy + ? "Die Auswertung läuft noch." + : problem + ? problemText(problem) + : null; + const disabled = !online || unplaced || busy || problem !== null || send.isPending; + + return ( +
+ +

+ Der Text geht zur Auswertung an Anthropic. Nur unveränderte Diktat-Teile werden gesendet. + {hint ? ` ${hint}` : ""} +

+
+ {send.isPending ? "Wird gesendet …" : null} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/lib/voice/dictated-text.spec.ts b/ApiCore-Frontend/src/lib/voice/dictated-text.spec.ts new file mode 100644 index 0000000..b4e5cd5 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/dictated-text.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + DICTATED_TEXT_MAX, + DICTATED_TEXT_MIN, + dictatedTextProblem, + emptyVoiceFields, + joinDictatedText, + normalizeForLength, + stillPresentChunks, +} from "./dictated-text"; + +describe("joinDictatedText", () => { + it("joins the chunks with one space and keeps line feeds and inner spaces", () => { + expect(joinDictatedText([" Königin gesehen", "Futter\nniedrig "])).toBe( + "Königin gesehen Futter\nniedrig", + ); + }); + + it("is empty without chunks", () => { + expect(joinDictatedText([])).toBe(""); + }); +}); + +describe("normalizeForLength (mirrors the backend normalizeInputText)", () => { + it("expands NFKC so the check counts what the server counts", () => { + const normalized = normalizeForLength("…".repeat(1400)); + expect(normalized).toBe(".".repeat(4200)); + expect(dictatedTextProblem(normalized)).toBe("long"); + }); + + it("counts code points, not UTF-16 units", () => { + const emoji = "😀".repeat(DICTATED_TEXT_MIN); + expect(emoji.length).toBe(DICTATED_TEXT_MIN * 2); + expect(dictatedTextProblem(normalizeForLength(emoji))).toBeNull(); + expect(dictatedTextProblem(normalizeForLength("😀".repeat(DICTATED_TEXT_MIN - 1)))).toBe("short"); + }); + + it("removes zero-width and control characters before counting", () => { + const padded = "a\u200B".repeat(DICTATED_TEXT_MIN) + "\u0007"; + expect(padded.length).toBeGreaterThan(DICTATED_TEXT_MIN); + expect(normalizeForLength(padded)).toBe("a".repeat(DICTATED_TEXT_MIN)); + expect(dictatedTextProblem(normalizeForLength("a\u200B".repeat(10)))).toBe("short"); + }); + + it("turns tabs, lone carriage returns and line separators into spaces and keeps line feeds", () => { + expect(normalizeForLength("a\tb\rc\u2028d\u2029e\nf\r\ng")).toBe("a b c d e\nf\r\ng".replace("\r\n", "\n")); + }); + + it("trims", () => { + expect(normalizeForLength(" \n text \n ")).toBe("text"); + }); + + it("uses the same steps as the backend source", () => { + const path = resolve(process.cwd(), "../ApiCore-Backend/src/voice/core/voice-text.ts"); + if (!existsSync(path)) return; + const source = readFileSync(path, "utf8"); + expect(source).toContain(".normalize('NFKC')"); + expect(source).toContain(String.raw`.replace(/\r(?!\n)|[\t\u2028\u2029]/g, ' ')`); + expect(source).toContain(String.raw`.replace(/[^\P{Cc}\n]/gu, '')`); + expect(source).toContain(String.raw`const FORMAT_CHARACTERS = /\p{Cf}/gu;`); + }); +}); + +describe("dictatedTextProblem", () => { + it("accepts the limits and names the problem outside them", () => { + expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MIN))).toBeNull(); + expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MAX))).toBeNull(); + expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MIN - 1))).toBe("short"); + expect(dictatedTextProblem("a".repeat(DICTATED_TEXT_MAX + 1))).toBe("long"); + }); +}); + +describe("stillPresentChunks", () => { + it("drops chunks the beekeeper deleted from every free-text field", () => { + const fields = ["Varroa kaum zu sehen", "", "", ""]; + expect(stillPresentChunks(["Varroa kaum", "Futter niedrig"], fields)).toEqual(["Varroa kaum"]); + }); + + it("matches ignoring surrounding white space", () => { + expect(stillPresentChunks([" Futter niedrig "], ["Notiz: Futter niedrig."])).toEqual([ + " Futter niedrig ", + ]); + }); +}); + +describe("emptyVoiceFields", () => { + it("lists the suggestible fields that hold no answer", () => { + const empty = emptyVoiceFields({ + queenSeen: true, + eggsPresent: null, + gentleness: null, + colonyStrength: "", + broodFrames: "6", + feedStatus: null, + notes: " ", + measures: "Futter gegeben", + } as never); + expect(empty.has("eggsPresent")).toBe(true); + expect(empty.has("gentleness")).toBe(true); + expect(empty.has("colonyStrength")).toBe(true); + expect(empty.has("feedStatus")).toBe(true); + expect(empty.has("notes")).toBe(true); + expect(empty.has("queenSeen")).toBe(false); + expect(empty.has("broodFrames")).toBe(false); + expect(empty.has("measures")).toBe(false); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/dictated-text.ts b/ApiCore-Frontend/src/lib/voice/dictated-text.ts new file mode 100644 index 0000000..1527e94 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/dictated-text.ts @@ -0,0 +1,58 @@ +import { VOICE_FIELD_LABELS } from "./suggestions"; +import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values"; + +/** The server takes 20 to 4000 characters (code points) after its own normalisation. */ +export const DICTATED_TEXT_MIN = 20; +export const DICTATED_TEXT_MAX = 4000; + +/** + * Mirrors ApiCore-Backend normalizeInputText (voice-text.ts): NFKC; tabs, lone + * carriage returns and U+2028/U+2029 become spaces; control characters except + * the line feed and format characters (zero-width, bidi marks) are removed; + * trimmed. No white space is collapsed, line feeds stay. What is checked here + * is what is sent, so client and server count the same text. + */ +export function normalizeForLength(text: string): string { + return text + .normalize("NFKC") + .replace(/\r(?!\n)|[\t\u2028\u2029]/g, " ") + .replace(/[^\P{Cc}\n]/gu, "") + .replace(/\p{Cf}/gu, "") + .trim(); +} + +/** The dictated pieces as one text, normalised like the server does. */ +export function joinDictatedText(chunks: readonly string[]): string { + return normalizeForLength(chunks.join(" ")); +} + +export function dictatedTextProblem(text: string): "short" | "long" | null { + const length = Array.from(text).length; + if (length < DICTATED_TEXT_MIN) return "short"; + if (length > DICTATED_TEXT_MAX) return "long"; + return null; +} + +/** + * Only what is still written in a free-text field leaves the device: a piece + * the beekeeper has deleted since is not sent. + */ +export function stillPresentChunks( + chunks: readonly string[], + fieldValues: readonly string[], +): string[] { + return chunks.filter((chunk) => { + const piece = chunk.trim(); + return piece !== "" && fieldValues.some((value) => value.includes(piece)); + }); +} + +function isEmptyAnswer(value: unknown): boolean { + return value === null || value === undefined || (typeof value === "string" && value.trim() === ""); +} + +/** The fields a suggestion may fill that hold no answer yet. */ +export function emptyVoiceFields(values: InspectionFormValues): ReadonlySet { + const record = values as unknown as Record; + return new Set(Object.keys(VOICE_FIELD_LABELS).filter((field) => isEmptyAnswer(record[field]))); +}