Four parts of measuring and installing, and not one line of C#. This part fixes that: the clinic's Blazor Server app gets a chat client over the local runtime, a service that streams answers, and a page in the staff area where a receptionist can type a question. It ends with a screenshot of the assistant confidently telling a first-time patient to bring an insurance card. The clinic does not take insurance cards. That answer is the whole argument for Parts 6 and 7.

ClinicLive staff area with a new 'Assistant' item highlighted in the navigation. A card titled Assistant with the line 'Answers come from the clinic's own server. Nothing leaves the building.' The transcript shows a right-aligned bubble 'What should a first-time patient bring?' and the assistant's reply: bring identification, an insurance card if they have one, relevant medical records or test results, and a list of current medications. Below, the input 'Ask the assistant…' and a Send button.
The first answer the staff assistant ever gave, captured by the Playwright harness against the real model on the dev machine. Fluent, polite, and wrong for this clinic: it knows nothing about Sunrise Family Clinic yet, so it answers for the average clinic on the internet. Compare it with the sheet in Part 2: appointment code, photo ID, previous prescriptions.

Two packages, one interface

The .NET side of local AI has settled on one abstraction: Microsoft.Extensions.AI defines IChatClient and IEmbeddingGenerator, and OllamaSharp implements both against Ollama's HTTP API. That means the application never mentions Ollama outside one registration line; if the runtime changes next year, the service and the page do not. Two package references, an Ai section in appsettings with the endpoint, the chat model, the embedding model, the keep-alive and the number of context chunks for later, and a small options record bound once at startup in the same plain-configuration style the app already uses for its clinic settings.

builder.Services.AddSingleton<IChatClient>(
    new OllamaApiClient(new Uri(ai.Endpoint), ai.ChatModel));
builder.Services.AddScoped<AssistantService>();

The service: a system prompt and a stream

AssistantService has one public method for now: given a question and an audience, it builds a system prompt from a template and returns an IAsyncEnumerable of text deltas from the streaming chat call. The template is a constant string in code, not configuration, because it is part of the application's behaviour and belongs in version control with the tests that check it. It says who the assistant is, using the clinic name from settings, that it answers briefly, that it never gives medical advice, doses or medicine names, that anything medical gets "please speak to the doctor", that an emergency gets "call 108", and that uncertainty gets "I don't know that; please ask reception."

Three unit tests pin those sentences to the prompt. They are cheap, they run without a model, and they failed on the first run: the assistant's raw-string literal had wrapped one forbidden phrase across a line break, so the test's exact-substring check could not find "never name a medicine". Reflowing the template fixed it. A small thing, but it is the first mistake of the season that a test caught rather than a person.

The page: a chat panel that streams

/staff/assistant sits behind the same authorization attribute as the other staff pages and reuses the chat classes from season one's staff chat, so it looks like it belongs. The component keeps the transcript in memory for the circuit, disables the input while a reply is streaming, shows "thinking…" until the first token arrives, and appends each delta to the assistant's bubble with an InvokeAsync and a StateHasChanged. On any exception it shows one fixed bubble, "The assistant is not available right now", and logs the real error; a model runtime that is down is a fact for the log, not a stack trace for the receptionist.

One Blazor detail bit on the first attempt: the natural name for the injected service, Assistant, is the name of the page's own class, and a member named after its enclosing type is a compile error. The season one Fixes post on exactly that error exists because the AI made the same mistake then; it made it again here and renamed the field.

The harness learns a new flag

The Playwright screenshot harness from season two gets an --assistant flag: log in as reception, open the page, type the question, press Enter, then poll the last bubble every 300 ms until its text has stopped changing for a second and a half, and save the picture. Streaming answers need that settle rule; a fixed wait either cuts the answer or wastes a minute. The run against the real model took a few seconds and produced the screenshot above.

What the answer teaches

Read the reply again. It is a good answer to "what should a first-time patient bring to a clinic", the question the model has seen ten thousand times in training. It is a wrong answer for this clinic, which asks for an appointment code and takes no insurance. Nothing in the system prompt could have fixed that, because the prompt does not know the clinic's rules either. The model needs the clinic's documents in front of it when it answers, and it needs to be told that if the documents do not say, it does not know. That is retrieval, and it is the next two parts.

Do it yourself

The application side of Part 5, file by file. Tag private-05 has the finished versions.

  1. Add the two packages to the web project:
    dotnet add src/ClinicLive package Microsoft.Extensions.AI
    dotnet add src/ClinicLive package OllamaSharp
  2. Add an Ai section to appsettings.json. The last two values arrive in Part 7 but cost nothing to add now:
    "Ai": {
      "Endpoint": "http://127.0.0.1:11434",
      "ChatModel": "qwen2.5:14b",
      "EmbeddingModel": "nomic-embed-text",
      "KeepAlive": "24h",
      "MaxContextChunks": 6,
      "MaxDistance": 0.55
    }
  3. Register one chat client in Program.cs. This is the Part 8 form, with the function-invocation wrapper already on; in Part 5 the inner line alone is enough:
    builder.Services.AddSingleton<Microsoft.Extensions.AI.IChatClient>(sp =>
        new Microsoft.Extensions.AI.ChatClientBuilder(
                new OllamaSharp.OllamaApiClient(new Uri(ai.Endpoint), ai.ChatModel))
            .UseFunctionInvocation()
            .Build(sp));
    builder.Services.AddScoped<ClinicLive.Services.Ai.AssistantService>();
  4. Write Services/Ai/AssistantService.cs with the system prompt as a constant. The safety sentences are fixed text the model is told to use, not text it generates. The opening of the real template:
    public const string SystemPromptTemplate = """
        You are the assistant for {0}. You help with questions about this clinic:
        opening hours, appointments, what to bring, where to park, how to check in.
    
        Answer briefly — two or three short sentences, plain language, no lists unless asked.
        Always answer in English, whatever language you think in.
    
        You are not a clinician and you never act like one.
        Never give medical advice, never name a medicine, never give a dose, never diagnose.
        For anything medical — symptoms, treatment, test results, whether something is serious —
        answer only: please speak to the doctor.
    
        If the question describes an emergency, tell the person to call 108 now.
    
        If you do not know the answer, or the clinic has not told you, say exactly:
        I don't know that; please ask reception.
        """;
    Expose AskAsync(question, audience, ct) returning an IAsyncEnumerable<string> of deltas from the client's streaming call.
  5. Build the page at /staff/assistant behind the same [Authorize] attribute as the other staff pages, reuse the chat classes, and append each delta to the last bubble. The loop, as it stands after Parts 7 and 8 added their two markers:
    await foreach (var delta in reply.Deltas)
    {
        if (delta == AssistantService.GuardMarker)
        {
            answer.Text = AssistantService.RefusalText;   // Part 7: uncited answer, replaced
            answer.Cites = [];
            continue;
        }
        if (delta == AssistantService.ResetMarker)
        {
            answer.Text = string.Empty;                    // Part 8: the model called a tool; clear the preamble
            continue;
        }
        answer.Text += delta;
        await InvokeAsync(StateHasChanged);
    }
  6. Pin the prompt with a unit test that asserts the clinic name and the three safety sentences are present, and keep it on one line each so a substring test cannot miss them.
  7. Run it: dotnet run --project src/ClinicLive, sign in as reception, ask what a first-time patient should bring.

What you should see: a fluent, polite answer about insurance cards and medication lists, which this clinic does not use. Save it; Part 7 asks the same question.

The brief that built it, trimmed to the lines that decided the result. Season one put every prompt in a commit message; this season's briefs were written to a coding assistant before each part, and this is what they said.

Packages: Microsoft.Extensions.AI and OllamaSharp. Ai section in appsettings read into an AiOptions record.
Services/Ai/AssistantService.cs, scoped: AskAsync(question, audience, ct) builds a system prompt from a const
template ({0} = clinic name: brief answers; never medical advice, doses or medicine names, say "please speak
to the doctor"; emergencies call 108; unsure means exactly "I don't know that; please ask reception.") and
yields streaming text deltas. Page /staff/assistant, [Authorize], reusing the .chat-* classes; disable the input
while streaming; on any exception one fixed bubble. Nav link. A unit test that the prompt contains the clinic
name and the three safety sentences; no tests that call Ollama. Harness flag --assistant "question" that waits
until the last bubble stops changing for 1.5 s.

Perishable facts, as of September 2026: Microsoft.Extensions.AI 10.10.0 and OllamaSharp 5.4.30. One gap in that pairing: OllamaSharp's native request has a keep-alive field, but its Microsoft.Extensions.AI adapter offers no way to set it from ChatOptions, so the value in configuration is documented but not yet sent. The server-side setting from Part 4 covers it; the code path is revisited when the ingest work calls the native client directly.

Model pick: this part is plumbing with a written spec, so it went to the cheaper model at medium effort, from a brief that named every file, class and behaviour. The brief took longer to write than the code took to build, which is the right way round.

What the AI got wrong: the local model invented an insurance card and a medication list for a clinic it knows nothing about. The coding assistant wrapped a test-critical sentence across a line, named a field after its own class, and could not find a way to send keep-alive through the abstraction. Three small fixes, one fundamental gap, and the gap is the interesting one.

The meter: the first build part. Parts 1 to 5 together, all measurements, briefs and this page, sit on the meter as one reading, published with the methodology in the retro; the running-cost reading from Part 3 is unchanged, since one screenshot's worth of answers is a few seconds of 120 W.

Checkpoint: tag private-05 in the repo. Run the app with Ollama up and the 14B model pulled, sign in as reception, open Assistant, and ask it what to bring. Save its answer; Part 7 asks the same question again.