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

@@ -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];
});
}