# Будівництво чітких голосів за допомогою Blazor і локальних LLM

## Десять Заповідей

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

<!--category-- ASP.NET, Blazor, AI, LLM, Whisper, Ollama -->
## Вступ

**У якому ми створюємо голос, -) =-}Седина, що має значення ''t, простягає ключі до королівства AI**

Тут мається на увазі, що ми маємо справу з інтерфейсами голосу: : * кожен хоче, щоб вони були ,}І ніхто їм не довіряє, але вони мають ., і вони мають право не на .)

Ви скажете, що 12 +MK5 і виштовхується LLM M" +МСК7, і йде до наступного поля перш ніж ви зможете виправити його . або гіршеМСК9 - це довір'я і автоМСК10submmує вашу форму з неправильною інформацією.

Час, коли ви дозволяєте LMM "control}МСК1' ваша форма тече}МСК2 Ви 'e ввели не-МСК4-чотирну скриньку в те, що повинно бути передбачуваним користувачем music .

Але що якщо б ми могли мати наш торт і з'їсти його теж =?}] вхідні для mouse, * LLM для перекладу }МСК2, але *детермінований код* для всього, що дійсно має значення, дорівнює ?}

Те, що ми сьогодні будуємо ''re -a "texTen Tepes episte " volume system, що використовує Thazor ServerSK5) local Whisper для peicesK}МС8МСК8'І локальна Ollama for leepSK9} Not unsective AI overspectalseM.)

[TOC]

## Десять Заповідей, що мають значення }МСК0'Інструкційних форм

Перш ніж ми пишемо будь-який код =,} нехай ''s встановить правила}МСК2'

1. **Мовлення - це вхідні дані, що втрачаються** }МСК0] Це ніколи не є джерелом істини ,' лише один шлях для захаращування полів
2. **LLM - перекладач** Він витягує структури даних від безладної pese se,) нічого більшого.
3. **Потік форми належить комп' ютеру станцій** За кодом -, можна визначити, яке поле буде далі.
4. **Підтвердження: policy}-}dren** }МК0's are declarate and pusived by code ,} Не судження LLM
5. **Користувач завжди може перевизначити** }-}------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
6. **Все працює локально** теперьМК0}Немає зовнішніх залежностей API для функціональних можливостей ядра
7. **Всі дії буде зареєстровано** теперьМК0}Чудовий аудиторійний шлях для усування вад}МСМК1}МСК2'І т.д., коли щось піде не так
8. **Коректність - це тип +-}Звичайно** -} Стандартне коректне значення ,} Не інтерпретація LLM
9. **Схема форми є декларнативною** ДІСК0 +ЙСОН визначає форму-крану =МСК1
10. **Невдача граційна** -} Коли вимова зникає ,} форма все ще працює

Тут 'οs як це виглядає у практиці}МСК1'

```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
```

Зверніть увагу на те, що LLM робить :} Вона перекладає .} Що ''s it}.} The state comping moints mopsK4} =00Options arepected .}Ця політика check}МСК6}Тут LLM-'des type of auto-'sconfirming or ask a user'se.'s

## Що це робить?

Нехай '} є чітким про об'єкт денного об'єкта :}

- **Без природної розмови** -} Це є ''t a tebot .' One field at a time .}
- **Без адаптивного допиту** Схема форм є статичною . LM має значення '' t імпровізація, що йде за -}МСК4
- **Не існує особистості "}Сасистанта "}** Д-д-д-д-д-д-д-д-д-в-в-в-в-в-в-в-у-у-у-у-у-у-у-у-у-у! ! !
- **Немає контексту у полях** }МК0} Кожна крапка є незалежною}ММСК1' LM має пам'ятати ваше ім'я, коли просить електронної поштиМСК3

Ці значення є функціями ,} Не mugи .} Це робить систему рівноцінною}МСК2' testable}МСК3} і заслуговує на довір'я .'s

## Структура проекту

Ми маємо намір побудувати окремий "Пітер Blazor" app}МСК1) Тут ''s Над архітектурою :}

```
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
```

**Architectecure нота # :}** Жодна служба не залежить від іншого "ММСК0," що має значення "МСК1," що має значення "МСК2," що знає про "ац."

## Встановлення інфраструктури

Першою має бути ,}Вам потрібен Wsper і Ollama, що працюють локально .} Додайте ці до вашого `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:
```

Після того, як ми піднімаємося вгору, ,} вичерпає модель Ollama}МСК1'

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

> **Чому локальний катет** Працювати все локально - це 'et про вартість -}МСК2's about testplution's ,'s neuctions ,} І дані }МСК5 Ваша трубка CI може продукувати ті ж контейнери, що й СМСК}Ва ваші тести впадають в ту ж кінцеву точку, що й кінцева точка .} No APITERC8}Немає мережевих даних, що залишають ваш комп'ютер .

**Зауваження}МсісК0}** The `base.en` Whisper модель швидкий і точний для English}.} Для production's ,} Розглянемо `small.en` або `medium.en` для кращої точності}МСК0}

## Форму форми, яка складається з форм, що позначаються у виразі se:}

Форми визначаються у JSO.} Це єдине джерело правди для того, що існують, і як вони поводяться :

```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
    }
  ]
}
```

Моделі КМСК0, які представляють цю модель:

```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);
```

Тому що схема - це декоративна схема. **додавання полів ніколи не потребує торкання державного комп' ютера**І форма автоматично включає в себе це значення з контролем перебігу (S.)

## Фаза Держави, яка має значення :}Часистична форма

Тут мається на увазі, що суть роботи є основною. :} **The state magine doesn''}t використовує AI**Логіка, яка визначає перехід, що базується на чіткому правилі

```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];
    }
}
```

Деканові переходи є явною і пробною частиною: :}

```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();
}
```

**ci point =:}** The `_currentFieldIndex` тільки просування на декламацію означає, що невдале або відмовлене підтвердження тримає вас на тому самому полі .' ї Користувач перебуває в контрольному .}

Зверніть увагу на логіку, що підтверджує логіку "МСК0," що означає "ММК1" - проста політика. `alwaysConfirm` =} автоМСК1ММСК2'Утожність або чутливе поле "МСК3" запитати користувача .

## The LMO'} Завдання : * Переклад лише

Dracor Ollama має одну роботу: }:} перетворити нерозбірливу людську мову в структуру поля значення }.}Тут ''s }МСК3'

```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);
```

**seps:}** Екстрактор може запропонувати `NeedsConfirmation: true`}МСК0, але політика підтвердження завжди має кінцевий сказати:"ММСК1," "ЛМММСК2," опція є дорадницькою тенденцією

І в цій перспективі є КМСК0

```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);
    }
}
```

**Чому температура ⇩0.1? сягає** Ми хочемо, щоб LLM був нудною і рівноцінним до ., що означає те ж саме, що й кожного разу, коли ММСК2, тіло КМК3, применшує творчість, що й ми хочемо, щоб дані були однаковою величиною.

Штамп системи висвітлюється про LLM}МСК0's обмежену ролю =:}

```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 видовжує , type format},} і виражає довіру }МСК2' Він також приймає рішення, що буде далі .'s

## Переглядач Audio Record}МСК0}Воронець WAV

Сторона JavaScript вміщує мікрофон і перетворює його на "МмК0кГн моно ВАВМСК1"

```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 };
})();
```

**Чому конвертувати на WAV'S?}** Whsper's missions personal personalize personalize semK0) but }.} No ,) always sembersion spective severalse canceled on the = tetags out of the server'seMSK5'} always se,) and the turnition is definatistic (same audio in msK8 tempsions out).}

The `convertToWav` function's seamples to 16}КМСХ1ІМСХ2'll keep you the WAV's bit-'twi сягаючи КМК4, but it's ''s in the Explicant's K00S}

## Компонування "Шазор УІМСК0" ДВАМСК1

UI показує поточне поле cele left },} З усіма полями, видимими на бічній панелі на правому боці =.} **Бічна панель має значення довіри**:} Користувачі можуть побачити, де вони знаходяться у формі ,} те, що вони мають ''ve вже відповів },} і що МСК4 йде далі }ММСК5'ІСНДСК6}

```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
```

Тут ''s the tazor page cyrus}МісК1'

```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>
```

## Оркестратор має значення :, що несе все разом

Диспетчер виправляє служби}МСК0," він має **не має власної логіки розгалужування**Він просто кличе служби в послідовності і передає результат з'єму МСК1

```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);
    }
}
```

Кожен крок є незалежним і проб'є #МСК0}Мерт **ніколи не торкатись мережі**.} LLM **ніколи не торкнеться штату**.

## Темний Mode}:} Змінні для Win

Темний режим реалізовано з нетиповими властивостями CSS}МсК0}

```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);
}
```

Перемикання теми відбувається за допомогою localStorage} :}

```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 };
})();
```

## 

Детермінатична архітектура йде в тесті ce.}ТутМСК1's a state templete perte for the Щасливий шлях #МСК2'

```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");
}
```

І тут мається на увазі, що негативне тести =-, а саме - впевненість у собі. **сили** Підтвердження незалежно від того, що LLM пропонує}МСК0}

```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);
}
```

І тест браузера PuppeerSharp, який фактично клацає річ:s

```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");
}
```

Повна програма для тестування працює на тестах, що працюють на сайті UI.}

## Чому ця архітектура важлива

Нехай '}] }Чому ми побудували його таким чином:

```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
```

У традиційному підході **все є LLM**.) Кожне рішення - це norse-}МексиК2) Кожне випробування є пробабілістичними .}Кожен вада - "}Ті просто іноді робить те, що "-unproducible і unexpectablemK6}

У нашому підході}МСК0 , що LLM робить **одна річ**}:) Переводить мову до структурних значень }МСК1} Все інше є детерміналістичним C}МСК2, що ви можете зневаджувати , * testc4} І причина про .} Коли щось іде не так, ви можете вислизнути, чому саме .}

Це ДІМК0, і 3,, і 4) }МСК3  LLM перекладає }МСК4-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а-а."

## Виконуйте свої обов'язки

1. Почати з' єднання з МСК0 * * * * * *  на на на на * *

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

2. Запустити app}МСК0}

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

3. Перейти до `http://localhost:5000`

4. Натисніть " і почніть говорити !

## Висновки

Д-р Харріс: "Ті-д-д-д-д-д-д-д-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р-р

- **Поведінкова поведінка** }МСК0}Тежна вихідна точка ,
- **Тестова логіка** теперьМК0}Основні тести для бізнес-конструкцій
- **Прийнятні рішення** -} Кожну фазу стану записано у журнал
- **Милосердна деградація** теперьМК0}Часиц ?' Type замість
- **Локальна операція** теперьМК0}Хмари не залежать від функцій ядра

Ідеться про те, що вони є антиМСК3ІМСК4 **підставити комп' ютер комп' ютер на належне місце**А не оракул, який робить рішення для нас.

Тепер йдіть побудувати щось там, де ви - сягнете не модель}ММСK1'Ідецид, що відбувається далі}МСК2'

## Іде в частині }МСК0}Череззвишшя КМСІМСК2

У даний момент користувач читає запрошення і говорить далі, але що, якщо система може *говорити назад*}МЗК0) +ММСК1 додає text-'stemK3' ужить зворотний зв'язок , = Перетворює це на повну ІМСК5Інтерактивну голос) }МСК6ССССССК7

- **Читання повідомлень уголос** }МСК0}ММСК1," скажімо, ваше повне ім'я #МСК2}
- **Підтвердити видобування** Дзвінок КМК0, який чув "ММСК1," означає, що КМСК4
- **Символи звукового стану** Озвучує sounds для запису "початок" * 
- **дл. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д.** -} Завершальна форма, не дивлячись на екран

Архітектура вже підготовлена #:} Автомат sa випромінює події =,} Координати оркестратора сервіси +.}Тут вихідний голос - це просто ще одна послуга, яка слухає ці події

*Наближається .}*

## Подальше читання

- [Використання " Докера " у зв'язку з залежностями розвитку](/blog/dockercomposedevdeps) -} Виконує локальні служби
- [HTMX з ASP00.}Часи](/blog/htmxwithaspnetcore) Азот UI - -' specification specified -)
- [The DiSE Cook's =:} Недовіряється Богам](/blog/blog-article-cooking-dise-part3-untrustworthy-gods) ⇩-} Чому LLM потрібно перевірити

Повнофункціональний код знаходиться у `Mostlylucid.VoiceForm` Проект у темі "МСК0"