# How to Implement Transcription Post-Processing Using FluidVoice's DictationPostProcessingService

> Implement transcription post-processing with FluidVoice's DictationPostProcessingService. Route raw transcripts through LLMs like OpenAI for refined text output.

- Repository: [ALTIC/FluidVoice](https://github.com/altic-dev/FluidVoice)
- Tags: how-to-guide
- Published: 2026-07-09

---

**FluidVoice’s `DictationPostProcessingService` lets you route raw speech-to-text transcripts through an LLM—whether OpenAI, Groq, or Apple Intelligence—before displaying the final text to the user.**

The **transcription post-processing** pipeline in the open-source FluidVoice app provides a clean, gated architecture for enhancing dictation output with AI. By separating the decision logic from the execution logic, the service ensures that post-processing only runs when properly configured and verified.

## Core Architecture and Flow

The post-processing system relies on two primary components: the **DictationAIPostProcessingGate**, which validates whether AI processing is allowed, and the **DictationPostProcessingService**, which orchestrates the LLM call.

### Step 1: Validate With the Gate

Before invoking any LLM, the UI layer must check `DictationAIPostProcessingGate.isConfigured(for:)` to verify that the user has enabled an AI provider and that the provider fingerprint is verified. This prevents accidental data leakage to unverified endpoints.

```swift
guard DictationAIPostProcessingGate.isConfigured(for: .primary) else {
    // Post-processing disabled; use raw transcript
    return rawText
}

```

*Source:* `DictationAIPostProcessingGate.isConfigured` in [`Sources/Fluid/Services/DictationAIPostProcessingGate.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/DictationAIPostProcessingGate.swift)

### Step 2: Resolve the AI Provider

When the gate passes, `DictationPostProcessingService.process(_:dictationSlot:)` internally calls `resolveProvider(settings:dictationSlot:)` to determine the concrete implementation. The resolution logic supports:

- **Built-in providers** (OpenAI, Groq, etc.) via `ModelRepository`
- **Custom providers** identified by a `custom:` prefix
- **Private AI integration** through `PrivateAIIntegrationService`
- **Apple Intelligence** on macOS 26+

*Source:* `resolveProvider` implementation in [`Sources/Fluid/Services/DictationPostProcessingService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/DictationPostProcessingService.swift) (lines 42-81)

### Step 3: Construct the Prompt and Execute

The service merges the raw transcript with the user-selected system prompt (`SettingsStore.effectiveDictationSystemPrompt`) and sends it to the resolved provider. For non-Apple providers, it constructs an `LLMClient.Config` with a default **temperature of 0.2**, the model identifier, endpoint, and API key, then calls `LLMClient.shared.call(config)`.

For **Apple Intelligence**, the service routes to `AppleIntelligenceProvider.process(...)` instead.

*Source:* LLM call block in [`Sources/Fluid/Services/DictationPostProcessingService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/DictationPostProcessingService.swift) (lines 19-33)

### Step 4: Format and Return Results

The LLM response is passed through `ASRService.applyGAAVFormatting` to fix punctuation and spacing quirks, then wrapped in a `DictationPostProcessingService.Result` struct containing the enhanced text.

*Source:* `Result` creation in [`Sources/Fluid/Services/DictationPostProcessingService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/DictationPostProcessingService.swift) (lines 71-75)

## Code Implementation Examples

### Basic Integration in a ViewModel

Use this pattern in a `@MainActor` context to ensure UI-bound state updates safely:

```swift
import Fluid

class DictationViewModel {
    @MainActor
    func finalizeTranscription(_ rawText: String, slot: SettingsStore.DictationShortcutSlot = .primary) async {
        guard DictationAIPostProcessingGate.isConfigured(for: slot) else {
            self.transcribedText = rawText
            return
        }

        do {
            let result = try await DictationPostProcessingService.shared.process(
                rawText,
                dictationSlot: slot
            )
            self.transcribedText = result.text
        } catch let error as AIProcessingError {
            self.transcribedText = rawText
            DebugLogger.shared.error("Post-processing failed: \(error)", source: "DictationVM")
        }
    }

    @Published var transcribedText: String = ""
}

```

The gate respects the user's prompt selection (`Off`, `Private AI`, or a specific provider), while `process` throws specific `AIProcessingError` cases including `noVerifiedProvider`, `missingModel`, `missingAPIKey`, and `emptyResponse`.

### Configuring a Custom LLM Provider

To route transcription post-processing through a custom endpoint, prefix the provider ID with `custom:` and store the API key and model in `SettingsStore`:

```swift
func testCustomProvider() async {
    SettingsStore.shared.selectedProviderID = "custom:my-custom-provider"
    SettingsStore.shared.providerAPIKeys["custom:my-custom-provider"] = "my-secret-key"
    SettingsStore.shared.selectedModelByProvider["custom:my-custom-provider"] = "gpt-4-mini"

    let raw = "the quick brown fox jumps over the lazy dog"
    
    do {
        let result = try await DictationPostProcessingService.shared.process(raw)
        print("Enhanced:", result.text)
    } catch {
        print("Failed:", error)
    }
}

```

The gate automatically verifies the custom endpoint via fingerprint comparison before allowing the request.

### Enabling Apple Intelligence on macOS 26+

For devices running macOS 26 or later, you can use the built-in Apple Intelligence provider without an API key:

```swift
#if canImport(FoundationModels)
if #available(macOS 26.0, *) {
    SettingsStore.shared.selectedProviderID = "apple-intelligence"
    // No API key required for Apple Intelligence
    let output = try await DictationPostProcessingService.shared.process("hello world")
    print(output.text)
}
#endif

```

The service detects the `apple-intelligence` identifier and routes the call to `AppleIntelligenceProvider.process(...)`.

## Key Source Files

| File | Path | Role |
|------|------|------|
| [`DictationPostProcessingService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/DictationPostProcessingService.swift) | [`Sources/Fluid/Services/DictationPostProcessingService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/DictationPostProcessingService.swift) | Central service that resolves providers, builds prompts, calls LLMs, and returns processed text. |
| [`DictationAIPostProcessingGate.swift`](https://github.com/altic-dev/FluidVoice/blob/main/DictationAIPostProcessingGate.swift) | [`Sources/Fluid/Services/DictationAIPostProcessingGate.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/Services/DictationAIPostProcessingGate.swift) | Decision-making logic that validates if AI post-processing is enabled and verifies provider fingerprints. |
| [`SettingsStore.swift`](https://github.com/altic-dev/FluidVoice/blob/main/SettingsStore.swift) | [`Sources/Fluid/SettingsStore.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/SettingsStore.swift) | Holds user selections for providers, models, API keys, and system prompts. |
| [`LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/LLMClient.swift) | [`Sources/Fluid/LLMClient.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/LLMClient.swift) | Generic LLM client used to send requests to chosen endpoints. |
| [`ASRService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/ASRService.swift) | [`Sources/Fluid/ASRService.swift`](https://github.com/altic-dev/FluidVoice/blob/main/Sources/Fluid/ASRService.swift) | Applies GAAV-specific formatting to raw LLM output. |

## Summary

- **Always check the gate** using `DictationAIPostProcessingGate.isConfigured(for:)` before attempting post-processing to respect user privacy settings.
- **Call `process(_:dictationSlot:)`** on `DictationPostProcessingService.shared` to handle provider resolution, prompt construction, and LLM execution in a single awaitable call.
- **Handle specific errors** from the `AIProcessingError` enum to provide graceful fallbacks when providers are unverified or misconfigured.
- **Support custom providers** by using the `custom:` prefix in the provider ID and storing credentials in `SettingsStore`.
- **Leverage Apple Intelligence** on macOS 26+ by setting the provider ID to `apple-intelligence` without requiring external API keys.

## Frequently Asked Questions

### What is the purpose of DictationAIPostProcessingGate?

The gate acts as a security and configuration validator. It ensures that transcription post-processing only occurs when the user has explicitly enabled an AI provider and that the provider's endpoint fingerprint matches the verified configuration, preventing accidental data transmission to untrusted servers.

### How do I add a custom provider to FluidVoice?

Prefix your provider identifier with `custom:` (e.g., `custom:my-endpoint`) and store the API key in `SettingsStore.shared.providerAPIKeys` under that same key. The `DictationPostProcessingService` will automatically route requests to your specified endpoint and verify it through the gate's fingerprint checking mechanism.

### Can I use transcription post-processing without an API key?

Yes, but only when using **Apple Intelligence** on macOS 26 or later. Set `SettingsStore.shared.selectedProviderID` to `apple-intelligence` and omit the API key. All other providers (OpenAI, Groq, custom endpoints) require a valid API key stored in `SettingsStore`.

### What happens if the LLM returns an empty response?

The `DictationPostProcessingService` throws `AIProcessingError.emptyResponse` when the LLM returns no content. You should catch this error in your view model and fall back to displaying the raw transcription text to ensure the user never loses their dictation input.