An assistant over your documents needs a useful answer when the documents cannot help. In my ASP.NET Core sample, that means returning a refusal instead of asking the model to improvise. But the refusal has consequences beyond the response text: it consumes an admitted question and, when the request completes normally, becomes part of the conversation history.
My previous article explains retrieval and citation checks. Here I follow the state changes around an unsuccessful answer. The sample contains three synthetic cooking lessons, so its behavior is small enough to inspect directly.
All implementation links below point to commit 6aceda29c9e29692def36b1defaaf4d06a434975 of cited-assistant-dotnet.
Admission happens before evidence lookup
In ChefService.cs, the service reserves a question before retrieving a passage:
var access = subscriptions.TryReserveQuestion(memberId);
if (!access.Allowed) throw new ChefAccessException(access.Reason);
Only after admission does the service find or create the conversation history and ask the lesson library for evidence. That ordering matters: a question can use an allowance even when no model call follows.
The allowance measures admitted questions, not successful answers or provider requests. Someone integrating this sample should make that policy visible to users. Otherwise, a refusal that saved an inference call could still produce a confusing allowance decrease.
The repository documents the distinction in its limits: refusals and model failures consume a reservation; rejected admission does not.
Missing evidence keeps the model out
The service initializes the result as a refusal. This excerpt comes before its conditional model branch:
var evidence = library.Find(question, history);
var answer = OutsideMaterial;
string? source = null;
var refused = true;
When evidence is null, the model branch is skipped. The response explains that no passage answers the question and suggests the sample's supported topics. It carries no source reference.
This is an application decision with an observable boundary. It does not rely on a model choosing to admit uncertainty. It also does not prove that retrieval catches every unsupported question: keyword retrieval can select an irrelevant passage. This branch handles the narrower condition that retrieval returned nothing.
The outside-material test asks who won an election and checks three properties:
Assert.True(reply.Refused);
Assert.Null(reply.Source);
Assert.Equal(0, model.Calls);
Checking the call counter matters. A refusal string alone would not establish that generation was skipped.
A failed model call takes a different path
If retrieval finds evidence, the service calls the configured model. Ordinary exceptions from that call are caught without exposing dependency error details. Cancellation is explicitly rethrown.
The service then validates the candidate. For a cited model, it delegates to IsValidAnswer. For another model implementation, it requires the candidate to equal the retrieved passage exactly. If validation fails, the service returns its unverified-answer refusal instead of the candidate.
These paths distinguish two situations: no retrieved passage, and no accepted answer from the selected passage. Neither establishes that the underlying documents contain no possible answer. They describe what this implementation could retrieve and accept.
Citation validation also has a limit: a matching source identifier does not prove that every generated claim follows from the passage. A refusal policy should not be described as a factual correctness guarantee.
A completed refusal remains in context
After either answer path, the service records a turn:
cancellationToken.ThrowIfCancellationRequested();
history.Add(new ChefTurn(question, answer, source, time.GetUtcNow()));
if (history.Count > HistoryLimit)
history.RemoveRange(0, history.Count - HistoryLimit);
There is no success-only condition around that append. A normally completed refusal enters history with its refusal text and null source. History retains at most twenty turns per conversation, and its key includes both member and conversation identifiers.
Cancellation is different. If cancellation propagates before the append, that request does not add a completed turn through this code path. The earlier reservation has already happened; the service shows no refund operation.
Keeping a refusal in history does not turn it into evidence. The model-input contract explicitly separates conversational history from the selected evidence passage.
What I checked, and what I did not run
For this article, I retrieved the public repository files with curl at the fixed commit and inspected the service, contracts, README, and visible test cases. I inspected the outside-material assertions above and the unknown-member test, which checks that rejected admission makes no model call.
I ran no build, automated test suite, API session, or real-provider integration test for this article. The README records a previous 49-test pass; that is recorded validation, not a fresh result from this review.
Subscription, quota, and conversation state are in memory. Restarting resets them, and multiple instances do not share them. These are prototype limits to address before relying on the policy across a deployed service.
If you want an assistant like this over your own documents, I offer it as a fixed-price project on Upwork: https://www.upwork.com/services/product/2115562756244627175
Top comments (0)