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