feat: Sprachnotiz in der Durchsicht, Info-Seite und Zähler (T7 L5 Tasks 20, 22)
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:
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"]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user