# Bygga deterministiska röstformer med Blazor och lokala LLMs

## De tio budordens väg

<datetime class="hidden">2026-01-05T14:30</datetime>

<!--category-- ASP.NET, Blazor, AI, LLM, Whisper, Ollama -->
## Inledning

**Där vi bygger en röst till MSK-to-MSK-form system som inte ger nycklarna till AI-riket**

Här är det med röstgränssnittet som alla vill ha dem och ingen litar på dem och de har rätt att inte göra det.

De flesta röstformulär misslyckas när modellen hör ett datum eller bestämmer sig för att använda " med hjälp av " och hoppa framåt till . och gå vidare till nästa fält innan du kan korrigera det på . eller ännu värre :.Det hallucinerar självförtroende och automatisk -.Submiterar ditt formulär med fel data på ..

I samma ögonblick som du låter en LLM-enhet styra din formulärflöde till , har du fått en icke-- och deterministisk svart låda till vad som borde vara en förutsägbar användarupplevelse.

Men tänk om vi kunde ha vår kaka och äta det också ? på röstingång för bekvämlighets skull , på LLM för översättningen men *Deterministisk kod* för allt som faktiskt spelar någon roll

Det där är vad vi bygger idag med ' och bygger idag.- på " på kommandon som överensstämmer med " på röstformulär med hjälp av Blazor Server på , på lokal Whisper för tal.- och - till text., och lokal Ollama för fältutdragning.. och inga molnberoenden... Inga oförutsägbara AI-överherrar.... Bara ren arkitektur...

[TOC]

## De tio budorden i rösten och - och aktiverade formulär

Innan vi skriver någon kod till , och låter ' på så vis fastställa reglerna.

1. **Talet är indata för förlust** Det är aldrig sanningens källa. Det är bara ett sätt att fylla fält.
2. **LLM är översättare** Det extraherar strukturerad data från stökigt tal.
3. **Statsmaskinen äger formflödet** Deterministisk kod bestämmer vilket fält som är nästa.
4. **Bekräftelsen är styrd av policy.** Tröskelvärden är deklarativa och verkställs genom kod.
5. **Användaren kan alltid åsidosätta** "- på rösten är bekvämlighet" , och inte tvång
6. **Allt körs lokalt** Inga externa API-beroenden för kärnfunktionalitet
7. **Varje interaktion är loggad** Fullständig verifieringskedja för felsökning av , på överensstämmelse med , och replay när saker går fel
8. **Validering är typ av --baserad** Standardvaliderare och inte LLM-tolkning
9. **Formschemat är deklarativt** JSON definierar formstrukturen och koden tolkar den.
10. **Misslyckandet är graciöst** När taligenkänningen misslyckas, fungerar formuläret fortfarande

Här är hur det ser ut i praktiken.

```mermaid
flowchart LR
    A[User Speaks] --> B[Whisper STT]
    B --> C[Raw Transcript]
    C --> D[Ollama LLM]
    D --> E[Extracted Value]
    E --> F{Validation}
    F -->|Pass| G{Policy Check}
    F -->|Fail| H[Show Error]
    G -->|High Confidence| I[Auto-Confirm]
    G -->|Low Confidence| J[Ask User]
    J --> K{User Confirms?}
    K -->|Yes| L[State Machine: Next Field]
    K -->|No| M[Retry Recording]
    I --> L
    H --> M

    style D stroke:#f96,stroke-width:2px
    style F stroke:#6f6,stroke-width:2px
    style G stroke:#ff9,stroke-width:2px
```

Lägg märke till vad LLM gör till : på att det översätter till . på att ' är det att ..Statsmaskinen bestämmer flödet... Valideringen bestämmer riktigheten...Den policykontroll som görs är inte LLM på -.Bestämmer om att automatiskt bekräfta eller fråga användaren.

## Vad det här gör är inte att göra

Låt oss vara tydliga om omfattningen av :

- **Inget naturligt samtal** Det här är inte en chattrobot utan ett fält i taget.
- **Inget adaptivt ifrågasättande** Formschemat är statiskt och LLM-formatet är inte improvisationsbaserat.
- **Ingen personlighet till stöd för "-personlighet** Den LLM extraherar värden och den gör inte småprat.
- **Inget sammanhang över fält** Varje extrahering är oberoende av . och LLM kommer inte ihåg ditt namn när du ber om e-post.

Dessa begränsningar är funktioner och inte buggar.De gör systemet förutsägbart.

## Projektstruktur

Vi bygger en fristående Blazor Server-app på . och här är 's arkitektur.

```
Mostlylucid.VoiceForm/
├── Config/
│   └── VoiceFormConfig.cs         # Configuration binding
├── Models/
│   ├── FormSchema/                # Form definitions
│   ├── State/                     # Session state
│   └── Extraction/                # LLM input/output
├── Services/
│   ├── Stt/                       # Speech-to-text (Whisper)
│   ├── Extraction/                # Field extraction (Ollama)
│   ├── Validation/                # Type-based validators
│   ├── StateMachine/              # Form flow control
│   └── Orchestration/             # Coordinates everything
├── Components/
│   └── Pages/                     # Blazor UI
└── wwwroot/js/
    └── audio-recorder.js          # Web Audio API capture
```

**Arkitekturnoten ska innehålla följande information:** Ingen tjänst beror på en annan maskin för " på nedåtläge för ." och utsugaren inte känner till den statliga maskinen på .. Validatorn känner inte till orkestern eller ..Detta gör att varje verk kan testas oberoende av varandra.

## Inrättande av infrastrukturen

Först behöver du Whisper och Ollama som kör lokalt och lägga till dessa till din `devdeps-docker-compose.yml`:

```yaml
services:
  whisper:
    image: onerahmet/openai-whisper-asr-webservice:latest
    container_name: whisper-stt
    ports:
      - "9000:9000"
    environment:
      - ASR_MODEL=base.en
      - ASR_ENGINE=faster_whisper
    volumes:
      - whisper-models:/root/.cache/huggingface
    restart: unless-stopped

  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama-models:/root/.ollama
    restart: unless-stopped

volumes:
  whisper-models:
  ollama-models:
```

Efter att ha snurrat upp till , ska du dra en modell för Ollama på :

```bash
docker exec -it ollama ollama pull llama3.2:3b
```

> **Varför det är lokalt?** Köra allt lokalt är att MSKK0 och data locality på din CI rörledning kan snurra upp samma containrar på . och dina tester träffade samma slutpunkter på . och ingen API-hastighetsbegränsningar för , och ingen nätverksvariabilitet eller data lämnar din maskin på ..

**Anteckningen ska innehålla följande uppgifter:** och `base.en` Whisper-modellen är snabb och korrekt för engelska och engelska för produktion. `small.en` eller `medium.en` för bättre noggrannhet

## De förklarande formdefinitionerna

Blanketter definieras i JSON-. och detta är den enda källan till sanning för vilka fält som finns och hur de beter sig

```json
{
  "id": "customer-intake",
  "name": "Customer Intake Form",
  "fields": [
    {
      "id": "fullName",
      "label": "Full Name",
      "type": "Text",
      "prompt": "Please say your full name.",
      "required": true
    },
    {
      "id": "dateOfBirth",
      "label": "Date of Birth",
      "type": "Date",
      "prompt": "What is your date of birth?",
      "required": true,
      "confirmationPolicy": {
        "alwaysConfirm": true
      }
    },
    {
      "id": "email",
      "label": "Email Address",
      "type": "Email",
      "prompt": "What is your email address?",
      "required": true
    },
    {
      "id": "phone",
      "label": "Phone Number",
      "type": "Phone",
      "prompt": "What is your phone number?",
      "required": false
    },
    {
      "id": "notes",
      "label": "Additional Notes",
      "type": "Text",
      "prompt": "Any additional notes?",
      "required": false
    }
  ]
}
```

De modeller som representerar detta

```csharp
public record FormDefinition(
    string Id,
    string Name,
    List<FieldDefinition> Fields);

public record FieldDefinition(
    string Id,
    string Label,
    FieldType Type,
    string Prompt,
    bool Required = true,
    ConfirmationPolicy? ConfirmationPolicy = null);

public enum FieldType
{
    Text,
    Date,
    Email,
    Phone,
    Choice  // Constrained vocabulary - especially important for voice
}

public record ConfirmationPolicy(
    bool AlwaysConfirm = false,
    double ConfidenceThreshold = 0.85);
```

För att schemat är deklarativt. **att lägga till fält kräver aldrig att röra statsmaskinen**Släpp en ny fältdefinition i JSON-, och formuläret innehåller automatiskt den med rätt flödeskontroll.

## Den statliga maskinens deterministiska formflöde

Här är kärninsikten. **den statliga maskinen använder inte AI**Det är en ren logik som bestämmer övergångar baserade på explicita regler.

```csharp
public class FormStateMachine : IFormStateMachine
{
    private FormSession _session = null!;
    private int _currentFieldIndex;

    public FormSession StartSession(FormDefinition form)
    {
        _session = new FormSession
        {
            Id = Guid.NewGuid().ToString(),
            Form = form,
            Status = FormStatus.InProgress,
            StartedAt = DateTime.UtcNow,
            FieldStates = form.Fields.ToDictionary(
                f => f.Id,
                f => new FieldState { FieldId = f.Id, Status = FieldStatus.Pending })
        };

        // First field starts in progress
        if (form.Fields.Count > 0)
        {
            _session.FieldStates[form.Fields[0].Id].Status = FieldStatus.InProgress;
        }

        return _session;
    }

    public FieldDefinition? GetCurrentField()
    {
        if (_currentFieldIndex >= _session.Form.Fields.Count)
            return null;

        return _session.Form.Fields[_currentFieldIndex];
    }
}
```

De statliga övergångarna är explicita och testerbara till :.

```csharp
public StateTransitionResult ProcessExtraction(
    ExtractionResponse extraction,
    ValidationResult validation)
{
    var currentField = GetCurrentField();
    if (currentField == null)
        return new StateTransitionResult(false, "No current field");

    var fieldState = _session.FieldStates[currentField.Id];
    fieldState.AttemptCount++;

    // Validation failed? Stay on current field
    if (!validation.IsValid)
    {
        return new StateTransitionResult(
            false,
            $"Validation failed: {validation.ErrorMessage}");
    }

    // Store the pending value
    fieldState.PendingValue = extraction.Value;
    fieldState.PendingConfidence = extraction.Confidence;

    // Check confirmation policy - this is rules, not AI
    var policy = currentField.ConfirmationPolicy
        ?? new ConfirmationPolicy();

    var needsConfirmation = policy.AlwaysConfirm
        || extraction.Confidence < policy.ConfidenceThreshold;

    if (needsConfirmation)
    {
        fieldState.Status = FieldStatus.AwaitingConfirmation;
        return new StateTransitionResult(
            true,
            "Please confirm this value",
            RequiresConfirmation: true);
    }

    // Auto-confirm high confidence values
    return ConfirmValue();
}
```

**Huvudpunkt på föredragningslistan** och `_currentFieldIndex` Endast förskott på bekräftelse på , på extraktionen detta innebär en misslyckad extraktion eller avvisad bekräftelse håller dig på samma fält .. . Användaren stannar i kontroll .. .

Lägg märke till konfirmationslogiken för att visa att det är en enkel policykontroll och inte ett LLM-resonemang. `alwaysConfirm` Be användaren om att få ett svar på din fråga.

## Översättningen är endast tillgänglig på svenska.

Ollama-extraktorn har ett jobb och förvandlar rörigt mänskligt tal till strukturerade fältvärden.

```csharp
public interface IFieldExtractor
{
    Task<ExtractionResponse> ExtractAsync(
        ExtractionContext context,
        CancellationToken ct = default);
}

public record ExtractionContext(
    FieldDefinition Field,
    string Prompt,
    string Transcript);

public record ExtractionResponse(
    string FieldId,
    string? Value,
    double Confidence,
    bool NeedsConfirmation,  // Suggestion only - policy has final say
    string? Reason);
```

**Viktigt att tänka på** Extraheraren kan föreslå `NeedsConfirmation: true`Men konfirmeringspolicyn har alltid det sista ordet för . på LLM-' och dess yttrande är rådgivande och inte auktoritativt.

Och genomförandet

```csharp
public class OllamaFieldExtractor : IFieldExtractor
{
    private readonly HttpClient _httpClient;
    private readonly string _model;

    public async Task<ExtractionResponse> ExtractAsync(
        ExtractionContext context,
        CancellationToken ct = default)
    {
        var systemPrompt = BuildSystemPrompt(context.Field);
        var userPrompt = $"User said: \"{context.Transcript}\"";

        var request = new
        {
            model = _model,
            messages = new[]
            {
                new { role = "system", content = systemPrompt },
                new { role = "user", content = userPrompt }
            },
            format = "json",
            stream = false,
            options = new { temperature = 0.1 }  // Low = deterministic
        };

        var response = await _httpClient.PostAsJsonAsync(
            "/api/chat", request, ct);

        return ParseResponse(response, context.Field.Id);
    }
}
```

**Varför temperaturen är så hög** Vi vill att LLM ska vara tråkiga och förutsägbara med tanke på samma utskrift av , ska det extrahera samma värde varje gång . och temperatur  på 0.1 och minimerar den kreativa variationen på - och exakt vad vi vill ha för dataextraktion.

Systemprompten är explicit om LLM-' och dess begränsade roll

```csharp
private string BuildSystemPrompt(FieldDefinition field)
{
    return $"""
        You are a data extraction assistant. Extract the {field.Label}
        from the user's speech.

        Field type: {field.Type}

        Return JSON only:
        {{
          "fieldId": "{field.Id}",
          "value": "<extracted value or null>",
          "confidence": <0.0-1.0>,
          "needsConfirmation": <true/false>,
          "reason": "<brief explanation>"
        }}

        Rules:
        - For dates, output ISO format (YYYY-MM-DD)
        - For emails, output lowercase
        - For phones, output digits only
        - If you can't extract, set value to null
        - Be conservative with confidence scores

        DO NOT:
        - Ask follow-up questions
        - Suggest next steps
        - Make assumptions beyond the transcript
        """;
}
```

LLM-utdragen till , och rapporterar konfidens Det bestämmer inte vad som händer härnäst.

## Ljudinspelare till WAV

JavaScript-sidan fångar mikrofonljud och konverterar det till 16 på Hz mono WAV-:

```javascript
window.voiceFormAudio = (function () {
    let mediaRecorder = null;
    let audioChunks = [];
    let audioContext = null;
    let dotNetRef = null;

    async function startRecording() {
        const stream = await navigator.mediaDevices
            .getUserMedia({ audio: true });

        audioContext = new AudioContext();
        audioChunks = [];

        const options = { mimeType: 'audio/webm;codecs=opus' };
        mediaRecorder = new MediaRecorder(stream, options);

        mediaRecorder.ondataavailable = (event) => {
            if (event.data.size > 0) {
                audioChunks.push(event.data);
            }
        };

        mediaRecorder.onstop = async () => {
            stream.getTracks().forEach(track => track.stop());

            const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
            const wavBlob = await convertToWav(audioBlob);
            const wavBytes = await wavBlob.arrayBuffer();

            // Send to Blazor
            const uint8Array = new Uint8Array(wavBytes);
            await dotNetRef.invokeMethodAsync(
                'OnRecordingComplete',
                Array.from(uint8Array));
        };

        mediaRecorder.start(100);
    }

    return { initialize, startRecording, stopRecording };
})();
```

**Varför konvertera till WAV-? på** Whisper accepterar olika format på , och men XSK1kHz mono WAV är optimal och - och ' är exakt vad modellen utbildades på utan omkodning på servern, , och konsekventa resultat och konverteringen är deterministisk.

och `convertToWav` Funktions-omprovtagningar till 16 och Hz på - och ' kommer att spara WAV-huvudet bit för - och twiddling på , på dig, men det är ' på repo och .

## Den Blazor UI-MSK-0-0-2---kolumnlayouten

UI visar det aktuella fältet tydligt på vänster sida med alla fält synliga i en sidofält på höger sida **Sidofältet är viktigt för tillit**Användarna kan se var de befinner sig i formuläret och vad de har svarat på , och vad som kommer härnäst utan överraskningar.

```mermaid
graph LR
    subgraph "Left Panel"
        A[Current Field Prompt]
        B[Record Button]
        C[Transcript Display]
        D[Confirmation Dialog]
    end

    subgraph "Right Sidebar"
        E[Field 1: Full Name ✓]
        F[Field 2: DOB - Active]
        G[Field 3: Email - Pending]
        H[Field 4: Phone - Pending]
    end

    style F stroke:#36f,stroke-width:2px
    style E stroke:#6f6,stroke-width:2px
```

Här är Blazors sidstruktur.

```razor
@page "/voiceform/{FormId}"
@inject IFormOrchestrator Orchestrator
@rendermode InteractiveServer

<div class="top-bar">
    <h1>Voice Form</h1>
    <button class="theme-toggle" onclick="voiceFormTheme.toggle()">
        Dark Mode
    </button>
</div>

<main>
    <div class="voice-form-layout">
        <!-- Left: Active Field -->
        <div class="active-field-panel">
            @if (_currentField != null)
            {
                <div class="current-prompt">
                    <h2>@_currentField.Label</h2>
                    <p class="prompt-text">@_message</p>
                </div>

                <AudioRecorder OnAudioCaptured="HandleAudioCaptured"
                               IsRecording="_isRecording"
                               IsProcessing="_isProcessing" />

                @if (!string.IsNullOrEmpty(_transcript))
                {
                    <TranscriptDisplay Transcript="_transcript"
                                       Confidence="_transcriptConfidence" />
                }

                @if (_showConfirmation)
                {
                    <ConfirmationDialog ExtractedValue="_pendingValue"
                                        OnConfirm="HandleConfirm"
                                        OnReject="HandleReject" />
                }
            }
        </div>

        <!-- Right: Form Overview -->
        <div class="form-sidebar">
            <h3>@_session.Form.Name</h3>
            <div class="form-fields-list">
                @foreach (var field in _session.Form.Fields)
                {
                    var state = _session.GetFieldState(field.Id);
                    <div class="form-field-item @GetFieldClass(field, state)">
                        <div class="field-status-icon">
                            @GetStatusIcon(state.Status)
                        </div>
                        <div class="field-info">
                            <div class="field-name">@field.Label</div>
                            <div class="field-value">
                                @(state.Value ?? "Waiting")
                            </div>
                        </div>
                    </div>
                }
            </div>
        </div>
    </div>
</main>
```

## Orkestern som för allt samman

Orkestratorn samordnar de tjänster den har **ingen egen förgreningslogik**Det bara ringer tjänster i följd och passerar resultat längs med :

```csharp
public class FormOrchestrator : IFormOrchestrator
{
    private readonly ISttService _sttService;
    private readonly IFieldExtractor _extractor;
    private readonly IFormValidator _validator;
    private readonly IFormStateMachine _stateMachine;
    private readonly IFormEventLog _eventLog;

    public async Task<ProcessingResult> ProcessAudioAsync(byte[] audioData)
    {
        var currentField = _stateMachine.GetCurrentField();
        if (currentField == null)
            return ProcessingResult.Error("No current field");

        // Step 1: Speech to text
        var sttResult = await _sttService.TranscribeAsync(audioData);
        await _eventLog.LogAsync(new TranscriptReceivedEvent(
            currentField.Id, sttResult.Transcript, sttResult.Confidence));

        // Step 2: Extract structured value
        var context = new ExtractionContext(
            currentField, currentField.Prompt, sttResult.Transcript);
        var extraction = await _extractor.ExtractAsync(context);
        await _eventLog.LogAsync(new ExtractionAttemptEvent(
            currentField.Id, extraction));

        // Step 3: Validate
        var validation = _validator.Validate(currentField, extraction.Value);

        // Step 4: State machine decides next step
        var transition = _stateMachine.ProcessExtraction(extraction, validation);

        return new ProcessingResult(
            Success: transition.Success,
            Message: transition.Message,
            Session: _stateMachine.GetSession(),
            RequiresConfirmation: transition.RequiresConfirmation,
            PendingValue: extraction.Value);
    }
}
```

Varje steg är oberoende och testbar till . på State Machine **Rör aldrig nätverket**"Mästare" "LLM" **Rör aldrig vid staten.**.

## Mörkt läge för CSS-variabler för vinsten

Mörkt läge implementeras med anpassade CSS-egenskaper och system

```css
:root {
    --bg-primary: #f8fafc;
    --bg-secondary: #ffffff;
    --text-primary: #1e293b;
    --accent-blue: #3b82f6;
    --accent-green: #22c55e;
}

[data-theme="dark"] {
    --bg-primary: #0f172a;
    --bg-secondary: #1e293b;
    --text-primary: #f1f5f9;
    --accent-blue: #60a5fa;
    --accent-green: #4ade80;
}

body {
    background: var(--bg-primary);
    color: var(--text-primary);
}
```

Temat att växla kvarstår via localStorage och : på lokal nivå

```javascript
window.voiceFormTheme = (function () {
    const THEME_KEY = 'voiceform-theme';

    function toggle() {
        const current = document.documentElement
            .getAttribute('data-theme') || 'light';
        const newTheme = current === 'dark' ? 'light' : 'dark';
        document.documentElement.setAttribute('data-theme', newTheme);
        localStorage.setItem(THEME_KEY, newTheme);
    }

    // Initialize from saved preference or system preference
    function init() {
        const saved = localStorage.getItem(THEME_KEY);
        const systemDark = window.matchMedia(
            '(prefers-color-scheme: dark)').matches;
        const theme = saved || (systemDark ? 'dark' : 'light');
        document.documentElement.setAttribute('data-theme', theme);
    }

    return { toggle, init };
})();
```

## Testning av : på enheten Tester på + på webbläsarens integrering

Den deterministiska arkitekturen lönar sig i uttestningsutgåvan av . och här är ett tillståndsmaskins-enhetstest för den lyckliga vägen till :

```csharp
[Fact]
public void ProcessExtraction_HighConfidence_AutoConfirms()
{
    // Arrange
    var form = CreateTestForm();
    var stateMachine = new FormStateMachine();
    stateMachine.StartSession(form);

    var extraction = new ExtractionResponse(
        FieldId: "fullName",
        Value: "John Smith",
        Confidence: 0.95,  // High confidence
        NeedsConfirmation: false,
        Reason: "Clear speech");

    var validation = ValidationResult.Success();

    // Act
    var result = stateMachine.ProcessExtraction(extraction, validation);

    // Assert
    result.Success.Should().BeTrue();
    result.RequiresConfirmation.Should().BeFalse();

    var fieldState = stateMachine.GetSession()
        .GetFieldState("fullName");
    fieldState.Status.Should().Be(FieldStatus.Confirmed);
    fieldState.Value.Should().Be("John Smith");
}
```

Och här är ett negativt test med lågt självförtroende. **krafter** bekräftelse oavsett vad LLM föreslår

```csharp
[Fact]
public void ProcessExtraction_LowConfidence_RequiresConfirmation()
{
    // Arrange
    var form = CreateTestForm();
    var stateMachine = new FormStateMachine();
    stateMachine.StartSession(form);

    var extraction = new ExtractionResponse(
        FieldId: "fullName",
        Value: "John Smith",
        Confidence: 0.65,  // Below threshold
        NeedsConfirmation: false,  // LLM says no, but policy overrides
        Reason: "Noisy audio");

    var validation = ValidationResult.Success();

    // Act
    var result = stateMachine.ProcessExtraction(extraction, validation);

    // Assert
    result.RequiresConfirmation.Should().BeTrue(
        "Policy threshold (0.85) should override LLM suggestion");

    var fieldState = stateMachine.GetSession()
        .GetFieldState("fullName");
    fieldState.Status.Should().Be(FieldStatus.AwaitingConfirmation);
}
```

Och ett PuppeterSharp browser test som faktiskt klickar på saker och ting

```csharp
[Fact]
public async Task HomePage_ThemeToggle_ShouldSwitchTheme()
{
    await _page!.GoToAsync(BaseUrl);
    await _page.WaitForSelectorAsync(".theme-toggle");

    var initialTheme = await _page.EvaluateFunctionAsync<string?>(
        "() => document.documentElement.getAttribute('data-theme')");

    // Click toggle
    await _page.ClickAsync(".theme-toggle");
    await Task.Delay(100);

    var newTheme = await _page.EvaluateFunctionAsync<string?>(
        "() => document.documentElement.getAttribute('data-theme')");

    newTheme.Should().NotBe(initialTheme);
}

[Fact]
public async Task VoiceFormPage_Sidebar_ShouldShowAllFields()
{
    await _page!.GoToAsync($"{BaseUrl}/voiceform/customer-intake");
    await _page.WaitForSelectorAsync(".form-fields-list");

    var fieldItems = await _page.QuerySelectorAllAsync(".form-field-item");

    fieldItems.Should().HaveCount(5, "Customer intake form has 5 fields");
}
```

Den fullständiga testsviten kör tester av enheten för affärslogiken med , och 22 på UI-. på webbläsarens integrationstest.

## Varför denna arkitektur är så viktig

Låt oss återbesöka varför vi byggde det så här.

```mermaid
graph TD
    subgraph "Traditional Voice Form"
        A1[User Speaks] --> B1[LLM]
        B1 --> C1[LLM decides field]
        C1 --> D1[LLM validates]
        D1 --> E1[LLM confirms]
        E1 --> F1[LLM advances form]
    end

    subgraph "Ten Commandments Approach"
        A2[User Speaks] --> B2[Whisper STT]
        B2 --> C2[Ollama Extract]
        C2 --> D2[C# Validator]
        D2 --> E2[Policy Check]
        E2 --> F2[State Machine]
    end

    style B1 stroke:#f96,stroke-width:2px
    style C1 stroke:#f96,stroke-width:2px
    style D1 stroke:#f96,stroke-width:2px
    style E1 stroke:#f96,stroke-width:2px
    style F1 stroke:#f96,stroke-width:2px

    style C2 stroke:#f96,stroke-width:2px
    style D2 stroke:#6f6,stroke-width:2px
    style E2 stroke:#6f6,stroke-width:2px
    style F2 stroke:#6f6,stroke-width:2px
```

I det traditionella tillvägagångssättet **Allt är LLM**Varje beslut är icke-- påfrestande . och varje test är probabilistisk.. på varje fel är  och det gör bara ibland att "- på ett oreproducerbart och oförklarligt.

I vårt tillvägagångssätt gör LLM **En sak till.**Allt annat är deterministiskt C.M.S.K.S.C.S.C.S.C.S.S.C.S.S.C.S.S.S.S.T.T.S.S.T.S.S.S.T.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.S.

Detta är budorden  och 3, och 4 på action och : på LLM översätts till , på statskontrollerna flow på , på policyn bestämmer bekräftelsen av . på detta sätt.

## Hur du sköter det själv

1. Starta beroendena på : och

```bash
docker compose -f devdeps-docker-compose.yml up -d
docker exec -it ollama ollama pull llama3.2:3b
```

2. Kör appen

```bash
cd Mostlylucid.VoiceForm
dotnet run
```

3. Navigera till `http://localhost:5000`

4. Klicka på "MSK" Customer Intake och börja prata !

## Slutsatser

Röstgränssnitt behöver inte vara svarta boxar och . på tal Genom att behandla tal som bara en annan indatametod eller LLMs som översättare och hålla flödeskontrollen i deterministisk kod så får du :

- **Förutsägbart beteende** Samma indata och samma utdata
- **Testbar logik** Enhetsprov för affärsregler
- **Revisionsgrundande beslut** Varje statsövergång loggas
- **Nådfull förnedring** Typ av röst felar ? eller typ istället
- **Lokal verksamhet** Inga molnberoenden för kärnfunktionalitet

De tre budorden är inte om att vara anti-- och AIB. och de handlar om **sätta AI på sin rätta plats**Ett kraftfullt verktyg som översätter mellan mänsklig rörighet och datorprecision och inte ett orakel som fattar beslut för oss.

Gå nu och bygg något där du inte modellen eller - och bestäm vad som händer härnäst.

## Kommer i del  och 2: på röståterkopplingar  med ( och IVR på )

Just nu kan användaren läsa uppmaningar och talar svar men vad händer om systemet kan *tala tillbaka*Del B1 kommer att lägga till text på - till - på tal feedback på , som gör detta till en fullständig IVR  på ( på Interactive Voice Response på ) och :

- **Läs uppmaningar högt** Säg ditt fullständiga namn.
- **Bekräfta extraktioner** Jag hörde John Smith och '.. Är det korrekt?
- **Ljudtillståndssignaler** Ljud för inspelningsstart med / och stopp för , på bekräftelse av ,-fel
- **Händerna ska vara fria** Fyll i formulär utan att titta på skärmen

Arkitekturen är redan förberedda på : och den statliga maskinen avger händelser på ,. Orkestratorn samordnar tjänster på . och lägger till röstutmatning är bara en annan tjänst som lyssnar på dessa händelser.

*Kommer snart.*

## Ytterligare läsning

- [Använda Docker Komposit för utvecklingsberoenden](/blog/dockercomposedevdeps) Inrätta lokala tjänster
- [HTMX med kärnkärna från ASP-MSK-NET](/blog/htmxwithaspnetcore) Tecknade användargränssnittsmönster för servern
- [De otillförlitliga Gudarna](/blog/blog-article-cooking-dise-part3-untrustworthy-gods) Varför LLM:er behöver verifieras

Den fullständiga källkoden finns i `Mostlylucid.VoiceForm` Projekt inom ramen för repo- och msk-programmet