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:
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])));
|
||||
}
|
||||
Reference in new issue
Block a user