From ebac3ef9d133f2fe280597ab13fa8af8d6e5ead1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:26:54 +0000 Subject: [PATCH] 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 ()") whenever it would take the recording, and not when the voice provider transcribes itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/ai-consent.test.ts | 21 +++++++++++++++++++++ apps/api/src/ai-consent.ts | 16 ++++++++++++++++ packages/adapters/src/ai-consent.ts | 3 ++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/api/src/ai-consent.test.ts b/apps/api/src/ai-consent.test.ts index 38f0a02..e76c3e7 100644 --- a/apps/api/src/ai-consent.test.ts +++ b/apps/api/src/ai-consent.test.ts @@ -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"; diff --git a/apps/api/src/ai-consent.ts b/apps/api/src/ai-consent.ts index 076eff7..e10c905 100644 --- a/apps/api/src/ai-consent.ts +++ b/apps/api/src/ai-consent.ts @@ -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({ diff --git a/packages/adapters/src/ai-consent.ts b/packages/adapters/src/ai-consent.ts index d633f84..437ee1c 100644 --- a/packages/adapters/src/ai-consent.ts +++ b/packages/adapters/src/ai-consent.ts @@ -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; }