feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
Backend Image / build-and-push (push) Successful in 5m31s
Frontend Image / build-and-push (push) Failing after 18m45s

Task 20: /inspections/new?voice=<id> zeigt die Sprachnotiz über dem
Formular (Abgeschriebenes, Unklares, „Alle übernehmen“ mit Rückfrage)
und Vorschläge unter den Feldern mit wörtlichem Zitat; nichts wird ohne
Tipp übernommen, die Volkskarte wird nie angehakt. Bei ausgeschalteter
Auswertung ist der Text offen zum Abtippen. Speichern schickt
voiceCaptureId; bei 409 VOICE_CAPTURE_GONE genau ein neuer Versuch ohne
Notiz mit demselben Idempotency-Key (online und aus der Warteschlange);
VOICE_CAPTURE_COLONY_MISMATCH nie automatisch, sondern „Ohne
Sprachnotiz speichern/senden“. Skalen aus lib/inspections/scales.ts mit
Paritätstest zum Backend.

Task 22: /settings/voice mit Modell und Fristen (bei ausgeschalteter
Auswertung ausdrücklich: nichts geht an einen KI-Dienst), „Alle meine
Sprachnotizen löschen“ auf Server und Gerät mit Anzahl; Systemkarte
„Sprachnotizen“ (nur system:manage) mit Zählern und Kosten der letzten
7 Tage (Ruling 15); Service Worker cacht /api/v1/voice-captures nie,
/voice und /voice/new offline erreichbar.

Gates: Frontend lint, typecheck, 3106 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 13:03:09 +02:00
1 parent 51206bea4f
commit ce922c8569
40 files changed
+3347 -45

No files matched your search

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { createInspection } from "./api";
import type { CreateInspectionInput } from "./types";
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
const INPUT: CreateInspectionInput = {
colonyId: "colony-1",
date: "2026-10-14",
queenSeen: true,
notes: "ruhig",
voiceCaptureId: "capture-1",
};
function bodyOf(call: number): Record<string, unknown> {
const init = vi.mocked(apiFetch).mock.calls[call][1] as RequestInit;
return JSON.parse(String(init.body)) as Record<string, unknown>;
}
function keyOf(call: number): unknown {
const init = vi.mocked(apiFetch).mock.calls[call][1] as RequestInit;
return (init.headers as Record<string, string>)["Idempotency-Key"];
}
const gone = () =>
new ApiError(409, "Die Sprachnotiz ist nicht mehr verfügbar.", {
code: "VOICE_CAPTURE_GONE",
});
describe("createInspection", () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
it("sends the voice capture id with the inspection", async () => {
vi.mocked(apiFetch).mockResolvedValue({ id: "i-1" });
const created = await createInspection(INPUT, { idempotencyKey: "key-1" });
expect(bodyOf(0)).toMatchObject({ voiceCaptureId: "capture-1", notes: "ruhig" });
expect(created).not.toHaveProperty("voiceCaptureGone");
});
it("a 409 VOICE_CAPTURE_GONE retries once without the id and keeps every input", async () => {
vi.mocked(apiFetch).mockRejectedValueOnce(gone()).mockResolvedValueOnce({ id: "i-1" });
const created = await createInspection(INPUT, { idempotencyKey: "key-1" });
expect(apiFetch).toHaveBeenCalledTimes(2);
expect(bodyOf(1)).toEqual({ ...bodyOf(0), voiceCaptureId: undefined });
expect(bodyOf(1)).not.toHaveProperty("voiceCaptureId");
expect(bodyOf(1)).toMatchObject({ queenSeen: true, notes: "ruhig", colonyId: "colony-1" });
// The first try failed and released its key: a repeat after a lost answer must still find the first record.
expect(keyOf(1)).toBe("key-1");
expect(created).toMatchObject({ id: "i-1", voiceCaptureGone: true });
});
it("retries only once", async () => {
vi.mocked(apiFetch).mockRejectedValue(gone());
await expect(createInspection(INPUT, { idempotencyKey: "key-1" })).rejects.toBeInstanceOf(
ApiError,
);
expect(apiFetch).toHaveBeenCalledTimes(2);
});
it("never retries a colony mismatch", async () => {
const mismatch = new ApiError(409, "Andere Volk", { code: "VOICE_CAPTURE_COLONY_MISMATCH" });
vi.mocked(apiFetch).mockRejectedValue(mismatch);
await expect(createInspection(INPUT)).rejects.toBe(mismatch);
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not retry other conflicts or inspections without a voice note", async () => {
vi.mocked(apiFetch).mockRejectedValue(gone());
const withoutVoice = { ...INPUT };
delete withoutVoice.voiceCaptureId;
await expect(createInspection(withoutVoice)).rejects.toBeInstanceOf(ApiError);
expect(apiFetch).toHaveBeenCalledTimes(1);
vi.mocked(apiFetch).mockReset().mockRejectedValue(new ApiError(409, "bereits verarbeitet"));
await expect(createInspection(INPUT)).rejects.toBeInstanceOf(ApiError);
expect(apiFetch).toHaveBeenCalledTimes(1);
});
});
+38 -3
View File
@@ -1,4 +1,4 @@
import { apiFetch } from "@/lib/auth/api-client";
import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
import {
CreateInspectionInput,
@@ -13,9 +13,9 @@ export function fetchColonyInspections(colonyId: string): Promise<Inspection[]>
return apiFetch<Inspection[]>(`/colonies/${encodeURIComponent(colonyId)}/inspections`);
}
export function createInspection(
function postInspection(
input: CreateInspectionInput,
options: CreateOptions = {},
options: CreateOptions,
): Promise<CreatedInspection> {
return apiFetch<CreatedInspection>("/inspections", {
method: "POST",
@@ -24,6 +24,41 @@ export function createInspection(
});
}
function isVoiceCaptureGone(error: unknown): boolean {
return (
error instanceof ApiError &&
error.status === 409 &&
(error.body as { code?: unknown } | null)?.code === "VOICE_CAPTURE_GONE"
);
}
/**
* Saves an inspection. When it carries a voice note that is gone by now
* (confirmed elsewhere, discarded, expired: 409 VOICE_CAPTURE_GONE), it is
* sent once more without the note and with the same input - what the
* beekeeper entered is never lost for a note they may not even have used.
* This also holds for queued inspections, which sync through this function.
* The failed try released its key, so the repeat keeps it: a lost answer to
* the repeat is then recognised on the next send instead of saved twice.
* A note of another colony (VOICE_CAPTURE_COLONY_MISMATCH) is never retried
* on its own: leaving a note out is the user's decision.
*/
export async function createInspection(
input: CreateInspectionInput,
options: CreateOptions = {},
): Promise<CreatedInspection> {
try {
return await postInspection(input, options);
} catch (error) {
if (input.voiceCaptureId === undefined || !isVoiceCaptureGone(error)) {
throw error;
}
const withoutVoice = { ...input };
delete withoutVoice.voiceCaptureId;
return { ...(await postInspection(withoutVoice, options)), voiceCaptureGone: true };
}
}
export function updateInspection(
id: string,
input: UpdateInspectionInput,
@@ -0,0 +1,61 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
INSPECTION_SCALE_DIRECTIONS,
INSPECTION_SCALE_LABELS,
type InspectionScaleField,
} from "./scales";
interface ScaleFixture {
field: string;
storedOne: string;
storedFive: string;
anchors: Record<string, number>;
}
// The one source both sides read: the backend prompt and this form.
const FIXTURE_PATH = path.resolve(
process.cwd(),
"../ApiCore-Backend/test/fixtures/voice/inspection-scales.json",
);
/** Which stored end is the good one - a fact about beekeeping, not derivable from the words. */
const GOOD_STORED_END: Record<InspectionScaleField, 1 | 5> = {
broodPattern: 5,
swarmMood: 1,
gentleness: 5,
combBehavior: 5,
};
// The frontend image builds without the backend folder: then there is nothing to compare.
describe.skipIf(!existsSync(FIXTURE_PATH))("inspection scales", () => {
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as ScaleFixture[];
it("scale directions equal the shared fixture", () => {
expect(Object.keys(INSPECTION_SCALE_DIRECTIONS).sort()).toEqual(
fixture.map((scale) => scale.field).sort(),
);
for (const scale of fixture) {
const field = scale.field as InspectionScaleField;
expect(INSPECTION_SCALE_LABELS[field], field).toEqual({
lowLabel: scale.storedOne,
highLabel: scale.storedFive,
});
expect(scale.anchors[scale.storedOne], field).toBe(1);
expect(scale.anchors[scale.storedFive], field).toBe(5);
expect(INSPECTION_SCALE_DIRECTIONS[field], field).toBe(
GOOD_STORED_END[field] === 5 ? "higher-is-better" : "lower-is-better",
);
}
});
});
describe("inspection scales (without the fixture)", () => {
it("mirrors only the swarm mood, whose stored 1 is the calm end", () => {
const mirrored = Object.entries(INSPECTION_SCALE_DIRECTIONS)
.filter(([, direction]) => direction === "lower-is-better")
.map(([field]) => field);
expect(mirrored).toEqual(["swarmMood"]);
});
});
@@ -0,0 +1,26 @@
import type { RatingDirection } from "@/lib/forms/rating";
export type InspectionScaleField = "broodPattern" | "swarmMood" | "gentleness" | "combBehavior";
/**
* Words for the STORED ends of the four 1-5 scales (1 = lowLabel, 5 = highLabel).
* The form and the voice prompt (ApiCore-Backend test/fixtures/voice/inspection-scales.json)
* must say the same: a mismatch would turn a spoken "ruhig" into the wrong number.
*/
export const INSPECTION_SCALE_LABELS: Readonly<
Record<InspectionScaleField, { lowLabel: string; highLabel: string }>
> = {
broodPattern: { lowLabel: "lückenhaft", highLabel: "geschlossen" },
swarmMood: { lowLabel: "ruhig", highLabel: "stark" },
gentleness: { lowLabel: "stechlustig", highLabel: "sehr sanft" },
combBehavior: { lowLabel: "läuft weg", highLabel: "sitzt ruhig" },
};
/** Which stored end is the good one. Only the swarm mood is stored the other way round. */
export const INSPECTION_SCALE_DIRECTIONS: Readonly<Record<InspectionScaleField, RatingDirection>> =
{
broodPattern: "higher-is-better",
swarmMood: "lower-is-better",
gentleness: "higher-is-better",
combBehavior: "higher-is-better",
};
@@ -49,6 +49,11 @@ export interface ColonyTakeover {
/** The answer to a new inspection: the inspection plus what reached the colony card. */
export interface CreatedInspection extends Inspection {
colonyTakeover?: ColonyTakeover;
/**
* Set by the client, never by the server: the voice note was gone (409
* VOICE_CAPTURE_GONE) and the inspection was saved once more without it.
*/
voiceCaptureGone?: true;
}
export interface CreateInspectionInput {
@@ -86,6 +91,12 @@ export interface CreateInspectionInput {
* (403 HIVE_FORBIDDEN otherwise). Sent only when there are ops.
*/
hiveChange?: { ops: HiveOp[] };
/**
* The voice note this inspection was reviewed from. The server confirms it
* and deletes its content in the same transaction (409 VOICE_CAPTURE_GONE /
* VOICE_CAPTURE_COLONY_MISMATCH when it cannot). Creating only; never drafted.
*/
voiceCaptureId?: string;
}
/** `null` clears an optional field, an omitted one stays unchanged. */
@@ -361,6 +361,48 @@ describe("offline queue database", () => {
expect(items.map((item) => item.canResendWithoutHiveChange ?? false)).toEqual([false, true]);
});
it("retryInspectionWithoutVoice strips the voice note, clears the rejection and keeps the key and the rest", async () => {
const db = await loadDb();
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-1" }, "key-v", "user-1");
const [entry] = await db.getPendingEntries("pending-inspections");
await db.markFailed("pending-inspections", entry.id, {
status: 409,
message: "Die Sprachnotiz gehört zu einem anderen Volk.",
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
at: REJECTION.at,
});
expect((await db.listQueue("user-1"))[0].canResendWithoutVoice).toBe(true);
await db.retryInspectionWithoutVoice(entry.id);
const [after] = await db.getPendingEntries("pending-inspections");
expect(after.input).toEqual(INSPECTION);
expect(after.lastError).toBeUndefined();
expect(after.idempotencyKey).toBe("key-v");
expect((await db.listQueue("user-1"))[0].canResendWithoutVoice).toBeUndefined();
});
it("offers „ohne Sprachnotiz“ only for a colony mismatch of an inspection that carries a note", async () => {
const db = await loadDb();
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-1" }, "k1", "user-1");
await db.queueInspection({ ...INSPECTION, voiceCaptureId: "cap-2" }, "k2", "user-1");
await db.queueInspection(INSPECTION, "k3", "user-1");
const [other, mismatch, plain] = await db.getPendingEntries("pending-inspections");
await db.markFailed("pending-inspections", other.id, REJECTION);
const mismatchError = {
status: 409,
message: "x",
details: { code: "VOICE_CAPTURE_COLONY_MISMATCH" },
at: REJECTION.at,
};
await db.markFailed("pending-inspections", mismatch.id, mismatchError);
await db.markFailed("pending-inspections", plain.id, mismatchError);
const items = await db.listQueue("user-1");
expect(items.map((item) => item.canResendWithoutVoice ?? false)).toEqual([false, true, false]);
});
it("summarizeQueue separates own, failed and foreign entries", async () => {
const db = await loadDb();
await db.queueInspection(INSPECTION, "key-1", "user-1");
+41 -2
View File
@@ -558,6 +558,37 @@ export async function retryInspectionWithoutHiveChange(entryId: number): Promise
await tx.done;
}
/** The refusal for a voice note that belongs to another colony than the inspection. */
function isVoiceColonyMismatch(error: QueueError | undefined): boolean {
const code = (error?.details as { code?: unknown } | null | undefined)?.code;
return error?.status === 409 && code === "VOICE_CAPTURE_COLONY_MISMATCH";
}
/** A queued inspection that carries a voice note. */
export function hasVoiceCapture(input: unknown): boolean {
return (input as { voiceCaptureId?: unknown } | null)?.voiceCaptureId !== undefined;
}
/**
* Ohne Sprachnotiz senden: takes the voice note out of a queued inspection and
* clears its rejection, so the next sync run sends the rest. The key stays:
* the server stored nothing for the rejected try. Only the user chooses this -
* a note of another colony is never left out on its own.
*/
export async function retryInspectionWithoutVoice(entryId: number): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-inspections", "readwrite");
const entry = await tx.store.get(entryId);
if (entry && hasVoiceCapture(entry.input)) {
const input = { ...entry.input };
delete input.voiceCaptureId;
const cleared: PendingEntry<CreateInspectionInput> = { ...entry, input };
delete cleared.lastError;
await tx.store.put(cleared);
}
await tx.done;
}
export interface QueueItem {
store: string;
id: number | string;
@@ -577,6 +608,8 @@ export interface QueueItem {
hasHiveChange?: boolean;
/** A rejected inspection whose hive change can be left out and the rest sent. */
canResendWithoutHiveChange?: boolean;
/** A rejected inspection whose voice note belongs to another colony: it can go without the note. */
canResendWithoutVoice?: boolean;
/**
* A rejected bulk entry that can go out without the colonies the server
* refused (`codes`: the ones named in the loaded packs, maybe fewer).
@@ -674,15 +707,21 @@ export async function listQueue(currentUserId: string): Promise<QueueItem[]> {
...(isHiveRefusal(entry.lastError) ? { canResendWithoutHiveChange: true } : {}),
}
: {};
const voice =
hasVoiceCapture((entry as PendingEntry<unknown>).input) && isVoiceColonyMismatch(entry.lastError)
? { canResendWithoutVoice: true }
: {};
if (!entry.idempotencyKey) {
return { ...item, ...hive };
return { ...item, ...hive, ...voice };
}
const photoCount = await db.countFromIndex(
"pending-photos",
"inspectionIdempotencyKey",
entry.idempotencyKey,
);
return photoCount > 0 ? { ...item, ...hive, photoCount } : { ...item, ...hive };
return photoCount > 0
? { ...item, ...hive, ...voice, photoCount }
: { ...item, ...hive, ...voice };
}),
);
const voiceItems = await readVoiceItems(db, currentUserId);
@@ -98,10 +98,12 @@ describe("service worker routes", () => {
"/treatments/new",
"/feedings/new",
"/swarm-control/new",
"/voice",
"/voice/new",
]) {
expect(routes.isShellRoute(route)).toBe(true);
}
expect(routes.SHELL_ROUTES).toHaveLength(11);
expect(routes.SHELL_ROUTES).toHaveLength(13);
});
it("unknown routes go to /offline and remember where the user wanted to go", () => {
@@ -224,6 +226,20 @@ describe("service worker routes", () => {
expect(cachesApi("/api/v1/systems")).toBe(true);
});
it("never caches voice notes - transcripts and suggestions are content", () => {
for (const path of [
"/api/v1/voice-captures",
"/api/v1/voice-captures/",
"/api/v1/voice-captures/c-1",
"/api/v1/voice-captures/config",
"/api/v1/voice-captures/stats",
"/api/v1/voice-captures?status=READY",
"/api/v1/Voice-Captures/c-1/retry",
]) {
expect(cachesApi(path)).toBe(false);
}
});
it("never caches exports and backups - full records incl. coordinates, addresses, megabytes", () => {
for (const path of [
"/api/v1/export/apiaries.json",
@@ -0,0 +1,92 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
// A queued inspection that carries a voice note (Task 20): it goes out through the real
// createInspection, so the „note is gone“ retry holds for queued entries as well.
vi.mock("@/lib/auth/api-client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/api-client")>()),
apiFetch: vi.fn(),
}));
vi.mock("@/lib/apiaries/offline-pack", () => ({ refreshPacksAfterSync: vi.fn() }));
type OfflineDbModule = typeof import("./db");
type SyncModule = typeof import("./sync");
let db: OfflineDbModule;
let sync: SyncModule;
let apiFetch: MockedFunction<typeof import("@/lib/auth/api-client").apiFetch>;
let ApiError: typeof import("@/lib/auth/api-client").ApiError;
const INSPECTION = {
colonyId: "colony-1",
date: "2026-10-14T00:00:00.000Z",
queenSeen: true,
voiceCaptureId: "cap-1",
};
function sentBody(call: number): Record<string, unknown> {
const init = apiFetch.mock.calls[call][1] as RequestInit;
return JSON.parse(String(init.body)) as Record<string, unknown>;
}
function sentKey(call: number): unknown {
const init = apiFetch.mock.calls[call][1] as RequestInit;
return (init.headers as Record<string, string>)["Idempotency-Key"];
}
describe("syncing a queued inspection with a voice note", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
window.localStorage.clear();
vi.resetModules();
db = await import("./db");
sync = await import("./sync");
const client = await import("@/lib/auth/api-client");
ApiError = client.ApiError;
apiFetch = vi.mocked(client.apiFetch);
apiFetch.mockReset();
});
it("a queued inspection with an expired voice capture is saved without it", async () => {
await db.queueInspection(INSPECTION, "key-queued", "user-1");
apiFetch
.mockRejectedValueOnce(new ApiError(409, "weg", { code: "VOICE_CAPTURE_GONE" }))
.mockResolvedValueOnce({ id: "server-1" });
const result = await sync.syncPendingEntries("user-1");
expect(result).toMatchObject({ synced: 1, failed: 0 });
expect(apiFetch).toHaveBeenCalledTimes(2);
expect(sentBody(0)).toMatchObject({ voiceCaptureId: "cap-1", queenSeen: true });
expect(sentBody(1)).toMatchObject({ queenSeen: true, colonyId: "colony-1" });
expect(sentBody(1)).not.toHaveProperty("voiceCaptureId");
expect(sentKey(1)).toBe("key-queued");
expect(await db.getPendingEntries("pending-inspections")).toEqual([]);
});
it("never drops a note of another colony by itself: it stays queued and can be sent without", async () => {
await db.queueInspection(INSPECTION, "key-queued", "user-1");
apiFetch.mockRejectedValueOnce(
new ApiError(409, "Die Sprachnotiz gehört zu einem anderen Volk.", {
code: "VOICE_CAPTURE_COLONY_MISMATCH",
}),
);
const result = await sync.syncPendingEntries("user-1");
expect(result).toMatchObject({ synced: 0, failed: 1 });
expect(apiFetch).toHaveBeenCalledTimes(1);
const [item] = await db.listQueue("user-1");
expect(item.canResendWithoutVoice).toBe(true);
apiFetch.mockResolvedValueOnce({ id: "server-1" });
await db.retryInspectionWithoutVoice(item.id as number);
const second = await sync.syncPendingEntries("user-1", { afterChange: true });
expect(second).toMatchObject({ synced: 1, failed: 0 });
expect(sentBody(1)).not.toHaveProperty("voiceCaptureId");
expect(sentKey(1)).toBe("key-queued");
});
});
@@ -18,6 +18,12 @@ describe("system labels", () => {
expect(JOB_LABELS.cleanup).toBe("Aufräumen");
});
it("labels the voice transcribe job and both voice events", () => {
expect(JOB_LABELS["voice-transcribe"]).toBe("Sprachnotizen abschreiben");
expect(DOMAIN_EVENT_LABELS.VoiceCaptureReady).toBe("Sprachnotiz fertig");
expect(DOMAIN_EVENT_LABELS.VoiceCaptureFailed).toBe("Sprachnotiz fehlgeschlagen");
});
it("labels the run and delivery states", () => {
expect(JOB_RUN_STATUS_LABELS.FAILED).toBe("Fehlgeschlagen");
expect(WEBHOOK_DELIVERY_STATUS_LABELS.DELIVERED).toBe("Zugestellt");
@@ -8,6 +8,7 @@ export const JOB_LABELS: Record<JobName, string> = {
cleanup: "Aufräumen",
"app-backup": "App-Backup",
rekey: "Schlüssel umstellen",
"voice-transcribe": "Sprachnotizen abschreiben",
};
export const JOB_RUN_STATUS_LABELS: Record<JobRunStatus, string> = {
+1
View File
@@ -4,6 +4,7 @@ export const JOB_NAMES = [
"cleanup",
"app-backup",
"rekey",
"voice-transcribe",
] as const;
export type JobName = (typeof JOB_NAMES)[number];
@@ -4,6 +4,7 @@ import {
discardAllVoiceCaptures,
discardVoiceCapture,
fetchVoiceCaptures,
fetchVoiceStats,
retryVoiceCapture,
submitVoiceText,
uploadVoiceCapture,
@@ -62,6 +63,11 @@ describe("voice api", () => {
expect(vi.mocked(apiFetch).mock.calls[3][0]).toBe("/voice-captures/discard-all");
});
it("reads the system counters from /voice-captures/stats", async () => {
await fetchVoiceStats();
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe("/voice-captures/stats");
});
it("has stable query keys", () => {
expect(voiceKeys.detail("1")).toEqual(["voice-captures", "1"]);
});
+13
View File
@@ -53,10 +53,19 @@ export interface VoiceClientConfig {
retention: { audioHours: number; contentDays: number; rowDays: number };
}
/** GET /voice-captures/stats (system:manage): counters of the last 7 days, no user data. */
export interface VoiceCaptureStats {
byStatus: Record<string, number>;
byError: Record<string, number>;
tokens: { input: number; output: number };
byModel: Record<string, number>;
}
export const voiceKeys = {
all: ["voice-captures"] as const,
detail: (id: string) => ["voice-captures", id] as const,
config: ["voice-captures", "config"] as const,
stats: ["voice-captures", "stats"] as const,
};
const BASE = "/voice-captures";
@@ -105,6 +114,10 @@ export function fetchVoiceConfig(): Promise<VoiceClientConfig> {
return apiFetch<VoiceClientConfig>(`${BASE}/config`);
}
export function fetchVoiceStats(): Promise<VoiceCaptureStats> {
return apiFetch<VoiceCaptureStats>(`${BASE}/stats`);
}
export function retryVoiceCapture(id: string): Promise<void> {
return apiFetch<void>(idPath(id, "retry"), { method: "POST" });
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { VOICE_MODEL_PRICES_USD, estimateVoiceCostUsd } from "./prices";
describe("voice model prices", () => {
it("lists the three allowed models with the spec 7.1 prices", () => {
expect(VOICE_MODEL_PRICES_USD["claude-sonnet-5-5"]).toEqual({ inputPerMillion: 2, outputPerMillion: 10 });
expect(VOICE_MODEL_PRICES_USD["claude-opus-5-5"]).toEqual({ inputPerMillion: 4, outputPerMillion: 20 });
expect(VOICE_MODEL_PRICES_USD["claude-haiku-5-5"]).toEqual({ inputPerMillion: 0.1, outputPerMillion: 0.5 });
});
it("multiplies tokens by the price of the model", () => {
expect(estimateVoiceCostUsd("claude-sonnet-5-5", { input: 1_000_000, output: 500_000 })).toBeCloseTo(7);
});
it("is exactly 0 without tokens", () => {
expect(estimateVoiceCostUsd("claude-sonnet-5-5", { input: 0, output: 0 })).toBe(0);
});
it("returns null for a model without a price, never a guess", () => {
expect(estimateVoiceCostUsd("unknown", { input: 10, output: 10 })).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
/*
* Price per million tokens in USD for the models the voice evaluation may use.
* Source: spec docs/superpowers/specs/2026-10-14-t7-sprache-design.md section
* 7.1 (Anthropic model list, as of 2026-10-06). Update together with
* VOICE_MODELS in the backend (src/voice/voice-config.ts) when prices change.
*/
export interface VoiceModelPrice {
inputPerMillion: number;
outputPerMillion: number;
}
export const VOICE_MODEL_PRICES_USD: Readonly<Record<string, VoiceModelPrice>> = {
"claude-opus-5-5": { inputPerMillion: 4, outputPerMillion: 20 },
"claude-sonnet-5-5": { inputPerMillion: 2, outputPerMillion: 10 },
"claude-haiku-5-5": { inputPerMillion: 0.1, outputPerMillion: 0.5 },
};
const TOKENS_PER_MILLION = 1_000_000;
/** Tokens x price; null when the model has no price in the table (never a guess). */
export function estimateVoiceCostUsd(
model: string,
tokens: { input: number; output: number },
): number | null {
const price = VOICE_MODEL_PRICES_USD[model];
if (!price) {
return null;
}
return (
(tokens.input * price.inputPerMillion + tokens.output * price.outputPerMillion) /
TOKENS_PER_MILLION
);
}
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { initialInspectionValues } from "@/app/(app)/inspections/new/inspection-form-values";
import type { VoiceSuggestion } from "./api";
import {
describeFieldValue,
describeSuggestionValue,
openSuggestions,
suggestionPatch,
} from "./suggestions";
const values = initialInspectionValues("colony-1");
function suggestion(
field: string,
value: VoiceSuggestion["value"],
extra: Partial<VoiceSuggestion> = {},
): VoiceSuggestion {
return { field, value, quote: "gesagt", corrected: false, truncated: false, ...extra };
}
describe("describeSuggestionValue", () => {
it.each([
[suggestion("queenSeen", true), "Königin gesehen: Ja"],
[suggestion("eggsPresent", false), "Stifte vorhanden: Nein"],
[suggestion("feedStatus", "WEAK"), "Futter: Niedrig"],
[suggestion("gentleness", 4), "Sanftmut: 4 von 5"],
[suggestion("broodFrames", 4), "Brutwaben: 4"],
[suggestion("colonyStrength", 1), "Volksstärke: 1 besetzte Wabe"],
[suggestion("colonyStrength", 12), "Volksstärke: 12 besetzte Waben"],
[suggestion("measures", "Honigraum aufgesetzt"), "Maßnahmen: Honigraum aufgesetzt"],
])("words %j as %s", (item, expected) => {
expect(describeSuggestionValue(item)).toBe(expected);
});
it("words a scale as the buttons show it - the swarm mood is mirrored", () => {
// Stored 1 is "ruhig", the good end, which the form shows on the right as 5.
expect(describeSuggestionValue(suggestion("swarmMood", 1))).toBe(
"Schwarmstimmung: 5 von 5 (ruhig)",
);
expect(describeFieldValue("swarmMood", 4)).toBe("2 von 5");
});
it("names the end word at the two ends of a scale", () => {
expect(describeFieldValue("gentleness", 5)).toBe("5 von 5 (sehr sanft)");
expect(describeFieldValue("gentleness", 1)).toBe("1 von 5 (stechlustig)");
expect(describeFieldValue("swarmMood", 5)).toBe("1 von 5 (stark)");
expect(describeFieldValue("broodPattern", 3)).toBe("3 von 5");
});
});
describe("suggestionPatch", () => {
it("turns each kind of value into the form's own shape", () => {
expect(suggestionPatch(suggestion("queenSeen", true), values)).toEqual({ queenSeen: true });
expect(suggestionPatch(suggestion("broodFrames", 4), values)).toEqual({ broodFrames: "4" });
expect(suggestionPatch(suggestion("colonyStrength", 8), values)).toEqual({
colonyStrength: "8",
});
expect(suggestionPatch(suggestion("swarmMood", 2), values)).toEqual({ swarmMood: 2 });
expect(suggestionPatch(suggestion("feedStatus", "CRITICAL"), values)).toEqual({
feedStatus: "CRITICAL",
});
});
it("appends free text to what is already there instead of replacing it", () => {
expect(suggestionPatch(suggestion("notes", "Königin markiert"), values)).toEqual({
notes: "Königin markiert",
});
expect(
suggestionPatch(suggestion("notes", "Königin markiert"), { ...values, notes: "Schwarz" }),
).toEqual({ notes: "Schwarz Königin markiert" });
});
it("gives nothing for a field or value the form cannot take", () => {
expect(suggestionPatch(suggestion("weather", "sonnig"), values)).toEqual({});
expect(suggestionPatch(suggestion("colonyId", "x"), values)).toEqual({});
expect(suggestionPatch(suggestion("queenSeen", "ja"), values)).toEqual({});
expect(suggestionPatch(suggestion("gentleness", 9), values)).toEqual({});
expect(suggestionPatch(suggestion("gentleness", 2.5), values)).toEqual({});
expect(suggestionPatch(suggestion("colonyStrength", 41), values)).toEqual({});
expect(suggestionPatch(suggestion("feedStatus", "ausreichend"), values)).toEqual({});
expect(suggestionPatch(suggestion("notes", " "), values)).toEqual({});
});
});
describe("openSuggestions", () => {
const all = [
suggestion("queenSeen", true),
suggestion("gentleness", 4),
suggestion("notes", "Neu"),
suggestion("weather", "sonnig"),
];
it("keeps the ones that would change something and drops unusable fields", () => {
expect(openSuggestions(all, values, new Set()).map((item) => item.field)).toEqual([
"queenSeen",
"gentleness",
"notes",
]);
});
it("drops a field already taken or ignored and one the beekeeper already answered the same", () => {
const answered = { ...values, queenSeen: true as boolean | null };
expect(
openSuggestions(all, answered, new Set(["notes"])).map((item) => item.field),
).toEqual(["gentleness"]);
});
it("skips free text the field already contains", () => {
const filled = { ...values, notes: "Schwarz, Neu gesehen" };
expect(openSuggestions([suggestion("notes", "Neu")], filled, new Set())).toEqual([]);
expect(
openSuggestions([suggestion("notes", "Neu")], { ...values, notes: "Alt" }, new Set()),
).toHaveLength(1);
});
it("keeps a differing answer open - a tap decides", () => {
const answered = { ...values, queenSeen: false as boolean | null };
expect(openSuggestions(all, answered, new Set()).map((item) => item.field)).toContain(
"queenSeen",
);
});
});
@@ -0,0 +1,199 @@
import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspection-form-values";
import { FEED_CONDITION_LABELS } from "@/lib/colonies/condition";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import type { ColonyCondition } from "@/lib/colonies/types";
import { toDisplayRating } from "@/lib/forms/rating";
import {
INSPECTION_SCALE_DIRECTIONS,
INSPECTION_SCALE_LABELS,
type InspectionScaleField,
} from "@/lib/inspections/scales";
import { appendDictation } from "./append-text";
import type { VoiceSuggestion } from "./api";
/*
* Turns what the server suggests into form values, in words, and decides what
* is still on offer. The server already checked every value; checking again
* here costs little and keeps a changed or damaged answer out of the form.
* Suggested text is data: it is only ever returned as strings for React to
* set as text.
*/
type Value = VoiceSuggestion["value"];
/** The fields a suggestion may fill, with the words the form uses for them. */
export const VOICE_FIELD_LABELS = {
queenSeen: "Königin gesehen",
eggsPresent: "Stifte vorhanden",
openBroodPresent: "Offene Brut",
cappedBroodPresent: "Verdeckelte Brut",
queenCellsPresent: "Weiselzellen",
broodPattern: "Brutbild",
broodFrames: "Brutwaben",
colonyStrength: "Volksstärke",
gentleness: "Sanftmut",
combBehavior: "Wabensitz",
swarmMood: "Schwarmstimmung",
feedStatus: "Futter",
honeySuperStatus: "Honigraum",
healthIssues: "Gesundheitsauffälligkeiten",
varroaIssues: "Varroa-Befund",
measures: "Maßnahmen",
notes: "Notizen",
} as const;
export type VoiceField = keyof typeof VOICE_FIELD_LABELS;
type FieldKind =
| { kind: "bool" }
| { kind: "count"; max: number }
| { kind: "scale" }
| { kind: "feed" }
| { kind: "text" };
const BOOL: FieldKind = { kind: "bool" };
const SCALE: FieldKind = { kind: "scale" };
const TEXT: FieldKind = { kind: "text" };
const FIELD_KINDS: Record<VoiceField, FieldKind> = {
queenSeen: BOOL,
eggsPresent: BOOL,
openBroodPresent: BOOL,
cappedBroodPresent: BOOL,
queenCellsPresent: BOOL,
broodPattern: SCALE,
gentleness: SCALE,
combBehavior: SCALE,
swarmMood: SCALE,
broodFrames: { kind: "count", max: MAX_OCCUPIED_FRAMES },
colonyStrength: { kind: "count", max: MAX_OCCUPIED_FRAMES },
feedStatus: { kind: "feed" },
honeySuperStatus: TEXT,
healthIssues: TEXT,
varroaIssues: TEXT,
measures: TEXT,
notes: TEXT,
};
const SCALE_MIN = 1;
const SCALE_MAX = 5;
export function isVoiceField(field: string): field is VoiceField {
return Object.hasOwn(FIELD_KINDS, field);
}
/** True for the free-text fields: their suggestions are added to what stands there. */
export function isVoiceTextField(field: string): boolean {
return isVoiceField(field) && FIELD_KINDS[field].kind === "text";
}
function isScaleField(field: VoiceField): field is VoiceField & InspectionScaleField {
return Object.hasOwn(INSPECTION_SCALE_DIRECTIONS, field);
}
function isFeedLevel(value: Value): value is ColonyCondition {
return typeof value === "string" && Object.hasOwn(FEED_CONDITION_LABELS, value);
}
function isIntIn(value: Value, min: number, max: number): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
}
/** The value as the form holds it, or undefined when the form cannot take it. */
function formValueOf(field: VoiceField, value: Value): unknown {
const spec = FIELD_KINDS[field];
switch (spec.kind) {
case "bool":
return typeof value === "boolean" ? value : undefined;
case "count":
return isIntIn(value, 0, spec.max) ? String(value) : undefined;
case "scale":
return isIntIn(value, SCALE_MIN, SCALE_MAX) ? value : undefined;
case "feed":
return isFeedLevel(value) ? value : undefined;
case "text":
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
}
}
function countText(count: number, one: string, many: string): string {
return count === 1 ? `1 ${one}` : `${count} ${many}`;
}
/** A value in words, as the form's buttons word it ("Ja", "Niedrig", "4 von 5"). */
export function describeFieldValue(field: string, value: Value): string {
if (!isVoiceField(field)) return String(value);
switch (FIELD_KINDS[field].kind) {
case "bool":
return value === true ? "Ja" : value === false ? "Nein" : String(value);
case "scale": {
if (!isScaleField(field) || typeof value !== "number") return String(value);
// Mirrored scales read as the buttons show them: left is always the bad end.
const text = `${toDisplayRating(value, INSPECTION_SCALE_DIRECTIONS[field])} von ${SCALE_MAX}`;
// At the two ends the word says what the number means.
const { lowLabel, highLabel } = INSPECTION_SCALE_LABELS[field];
if (value === SCALE_MIN) return `${text} (${lowLabel})`;
return value === SCALE_MAX ? `${text} (${highLabel})` : text;
}
case "feed":
return isFeedLevel(value) ? FEED_CONDITION_LABELS[value] : String(value);
case "count":
return field === "colonyStrength" && typeof value === "number"
? countText(value, "besetzte Wabe", "besetzte Waben")
: String(value);
case "text":
return String(value);
}
}
/** "Königin gesehen: Ja", "Futter: Niedrig", "Sanftmut: 4 von 5". */
export function describeSuggestionValue(suggestion: VoiceSuggestion): string {
const label = isVoiceField(suggestion.field)
? VOICE_FIELD_LABELS[suggestion.field]
: suggestion.field;
return `${label}: ${describeFieldValue(suggestion.field, suggestion.value)}`;
}
/**
* What taking this suggestion over changes in the form; empty when the field
* or value is not one the form can take. Free text is added to the end of
* what is there (like dictation), everything else replaces the field.
*/
export function suggestionPatch(
suggestion: VoiceSuggestion,
current: InspectionFormValues,
): Partial<InspectionFormValues> {
const { field } = suggestion;
if (!isVoiceField(field)) return {};
const value = formValueOf(field, suggestion.value);
if (value === undefined) return {};
if (FIELD_KINDS[field].kind === "text") {
return { [field]: appendDictation(current[field as "notes"], value as string) };
}
return { [field]: value };
}
/**
* The suggestions still on offer: usable, not yet taken or ignored, and not
* already answered with the same value by hand. Free text always counts as
* new (it is added, not compared).
*/
export function openSuggestions(
all: readonly VoiceSuggestion[],
values: InspectionFormValues,
taken: ReadonlySet<string>,
): VoiceSuggestion[] {
return all.filter((suggestion) => {
if (taken.has(suggestion.field)) return false;
const patch: Record<string, unknown> = suggestionPatch(suggestion, values);
const field = suggestion.field;
if (!(field in patch)) return false;
if (isVoiceTextField(field)) {
// Text the field already holds is not added a second time.
const suggested = String(suggestion.value).trim().toLowerCase();
const held = String((values as unknown as Record<string, unknown>)[field]).toLowerCase();
return !held.includes(suggested);
}
return (values as unknown as Record<string, unknown>)[field] !== patch[field];
});
}