.. SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
.. SPDX-License-Identifier: Apache-2.0
..
.. Licensed under the Apache License, Version 2.0 (the "License");
.. you may not use this file except in compliance with the License.
.. You may obtain a copy of the License at
..
.. http://www.apache.org/licenses/LICENSE-2.0
..
.. Unless required by applicable law or agreed to in writing, software
.. distributed under the License is distributed on an "AS IS" BASIS,
.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
.. See the License for the specific language governing permissions and
.. limitations under the License.

SIL-Wheel Docs Agent
====================

A Wheel deployment carries a lot of surface area: a dozen search modes, several
ways to combine them, and a set of workflows that only make sense once you know
which mode does what. New users ask the same questions repeatedly, and those
questions are almost always answered somewhere in these docs.

A small chat assistant, served alongside the Wheel UI and grounded in the
documentation, closes that gap. This page describes how we built ours, so you
can build an equivalent for your own deployment. The agent itself is not
distributed, but nothing here is difficult to reproduce, and the design matters
more than the code.

The decision that determines whether this works at all: **the model is never
asked what it knows about Wheel.** It is given a read-only tool over the
documentation source and told to answer only from what it reads.

This matters because an ungrounded model will confidently describe search modes
that do not exist, invent parameter names, and get the ranking rules subtly
wrong. Those answers are worse than no assistant at all, because they are
plausible. Grounding it in the docs means the failure mode becomes "I could not
find that", which is honest and actionable. It also means the assistant improves
whenever the docs improve, and never needs retraining when a feature ships.

Architecture
------------

The assistant is a standalone HTTP server, separate from the Wheel server. The
UI talks to it over a single ``POST /chat`` endpoint; everything else is
internal::

   Wheel UI widget  ──POST /chat──►  assistant server
                                        │
                                        ├── agent loop  ──►  LLM backend
                                        │        ▲              │
                                        │        └── tool calls ┘
                                        │
                                        └── docs tool (read-only)
                                                 │
                                                 └── docs source tree

Keeping it out of the Wheel server matters more than it first appears. The
assistant restarts when the docs change, holds an API key the Wheel server has
no business holding, and fails independently. A deployment without it loses a
chat widget and nothing else.

The Docs Tool
-------------

The tool is deliberately small. Three read-only operations are enough:

* **list** returns every doc with its name, title, and a one-line summary. This
  is what lets the model decide where to look without reading everything.
* **read** returns one doc in full, by name.
* **search** returns the docs matching a query string, with a snippet of the
  matching text from each.

Two details are worth copying. Strip the RST markup before handing text to the
model, since directives and roles are noise that costs tokens and confuses
smaller models. And build the doc index once at startup rather than walking the
tree per request; the index is small and the docs do not change while the
process is running.

Nothing writes. The tool cannot be made to modify a file, which removes an
entire category of risk from putting an LLM behind a public endpoint.

The Agent Loop
--------------

The loop is the standard one: send the conversation plus tool definitions to the
model, and if it asks for a tool, run it, append the result, and send again.
Repeat until the model answers instead of calling a tool.

Two bounds are worth setting from the start. Cap the number of iterations, so a
model that loops on tool calls fails fast rather than burning an API budget.
And sanitise the incoming conversation history, keeping only user and assistant
turns with string content, since anything else in that payload came from a
browser and should not be trusted.

Have the model name the docs it used, and rewrite those names into links to the
rendered pages before returning the answer. Citations are what make the
assistant trustworthy rather than merely convenient: a user who does not believe
an answer can check it in one click, and a user whose question was only partly
answered lands on the page covering the rest. They are also a cheap correctness
check, since an answer citing nothing, or citing a page unrelated to the
question, is one worth looking at.

Talk to the model through an OpenAI-compatible chat-completions interface and
select the provider from whichever API key is present in the environment. That
one indirection lets the same assistant run against a hosted endpoint, an
internal inference service, or a local model, without touching the loop.

Logging Questions
-----------------

Log every turn: the question, the answer, the citations, which tools were
called, the latency, and the error if it failed. SQLite is enough.

The value here is not observability, it is documentation feedback. The questions
people ask an assistant are the questions your docs answer badly. Clusters of
similar questions point straight at a missing section, and questions where the
model found nothing point at a gap. It is the most direct signal you will get
about which parts of your documentation are not working.

Class Design
------------

Four types carry the whole design. The signatures below are a starting point
rather than a required interface, but the split between them is the part worth
keeping: the tool knows nothing about models, the backend knows nothing about
docs, and the agent is the only thing that knows about both.

.. code-block:: python

   class DocsTool:
       """Read-only access to the documentation source tree."""

       def __init__(self, docs_root: Path) -> None:
           """Walk the tree once and build the name -> path index."""

       def list_docs(self) -> list[dict]:
           """Every doc as {name, path, title, summary}."""

       def read_doc(self, name: str) -> dict:
           """One doc as {name, path, title, text}, markup stripped."""

       def search_docs(self, query: str, max_results: int = 5) -> list[dict]:
           """Matching docs as {name, path, title, snippet}."""

``name`` is the doc's path without its extension (``search/caption_search``),
which is stable, readable in a citation, and maps to a URL with one
substitution.

.. code-block:: python

   class ChatBackend(Protocol):
       """Anything that speaks OpenAI-style chat completions."""

       def chat(self, messages: list[dict], tools: list[dict]) -> ChatResponse:
           """Return the assistant message plus any tool calls it requested."""

Keeping this a protocol rather than a concrete class is what makes the provider
swap free. A single implementation over an OpenAI-compatible HTTP API covers
every hosted and local option worth using.

.. code-block:: python

   @dataclass
   class AgentTurn:
       answer: str                      # final text, citations rewritten to links
       citations: list[str]             # doc names the answer drew on
       tool_invocations: list[dict]     # what was called, and whether it worked


   class DocsAgent:
       """The tool loop."""

       def __init__(
           self,
           tool: DocsTool,
           backend: ChatBackend,
           max_iterations: int = 6,
       ) -> None: ...

       def chat(self, message: str, history: list[dict] | None = None) -> AgentTurn:
           """Send, run any requested tool, repeat until the model answers."""

Returning ``tool_invocations`` alongside the answer costs nothing and pays for
itself the first time an answer is wrong: it shows whether the model looked in
the wrong place or read the right page and still got it wrong. Those are
different bugs, and one of them is yours.

What to Build First
-------------------

A useful assistant is a weekend of work, in roughly this order:

1. The docs tool, with list, read, and search over your docs source tree.
2. The agent loop, with a system prompt that says to answer only from the docs
   and to cite what it used.
3. A ``POST /chat`` endpoint and a plain HTML page that talks to it, which is
   enough to evaluate answer quality before you build any widget.
4. The QA log, ideally before you show the assistant to anyone, so the early
   questions are captured.
5. The UI widget, last, once the answers are good enough to be worth surfacing.

Points 1 to 3 are the assistant. Everything after that is polish.
