fix(media): disclose the transcription server before dictation uses it

Dictation falls back to the owner's transcription server when the voice
provider cannot transcribe, so that server is a voice recipient too: it is
listed for voice use (as "Transcription (<origin>)") whenever it would take
the recording, and not when the voice provider transcribes itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 14:26:54 +00:00
parent 97c7fd3a34
commit ebac3ef9d1
No known key found for this signature in database
3 changed files with 39 additions and 1 deletions

View File

@ -157,6 +157,27 @@ describe("consent grants", () => {
expect(JSON.stringify(status)).not.toContain("comfy-key");
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
});
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext-voice",
} as never);
deps.secrets = {
load: vi.fn(() =>
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
),
} as unknown as RouterDeps["secrets"];
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
credential: { provider: "kokoro" },
voiceId: "af_heart",
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
["Local voice", "voice"],
["Transcription (http://127.0.0.1:8080)", "voice"],
]);
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -9,6 +9,7 @@ import {
parseModelSecret,
selectConfiguredModel,
toStringRecord,
voiceCatalogEntry,
} from "@rakazo/adapters";
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
@ -186,6 +187,21 @@ export async function aiConsentStatus(
}
for (const voice of voices)
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
if (uses.includes("voice")) {
// Dictation goes to the owner's transcription server when the voice provider cannot transcribe.
const transcription = (await loadMediaSettings(deps)).transcription;
const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null;
if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "voice",
baseUrl: transcription.baseUrl,
modelId: transcription.model,
}),
);
}
}
if (includesMemory) {
if (memoryOwner) {
const memory = await deps.prisma.spaceMemoryConfig.findUnique({

View File

@ -52,6 +52,8 @@ export function aiRecipient(input: {
}
function recipientName(provider: string, use: AiDataUse) {
// The transcription server also takes dictation, so it keeps its name for voice use.
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
if (use === "voice") return voiceCatalogEntry(provider)?.name ?? provider;
if (use === "memory") {
if (provider === "supermemory") return "Supermemory";
@ -61,6 +63,5 @@ function recipientName(provider: string, use: AiDataUse) {
}
if (provider === "cursor") return "Cursor";
if (provider === MEDIA_COMFYUI_PROVIDER_ID) return "ComfyUI";
if (provider === MEDIA_TRANSCRIPTION_PROVIDER_ID) return "Transcription";
return listPiCatalog().find((entry) => entry.provider === provider)?.providerName ?? provider;
}