feat: Ganze Durchsicht aus Diktat auswerten (T7 L4 Task 21)

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) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 16:06:00 +02:00
1 parent 8b63c50f7a
commit f6e1f34baa
8 files changed
+944 -13

No files matched your search

@@ -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);
});
});
@@ -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<string> {
const record = values as unknown as Record<string, unknown>;
return new Set(Object.keys(VOICE_FIELD_LABELS).filter((field) => isEmptyAnswer(record[field])));
}