Dr Ike as a Pydantic AI agent¶
AI Mode's teaching answer can be written two ways. The prompt path asks a
model to write markdown in a particular shape — a mechanism paragraph, an
At the bedside heading with bullets, a Check yourself line — and
checks nothing about that shape afterwards. The agent path
(backend/app/services/tutor_agent.py) asks for the same three things as a
schema instead, so a model that wanders off it gets a validation error fed
back to it before the reply exists as text at all.
Retrieval, the citation contract, and the router's small-talk and mode
decisions are unchanged either way — they live in ai_mode_service.py and
routers/ai_mode.py, exactly as before. This document is only about the one
thing tutor_agent.py replaces: turning a question and a shortlist of
sources into the reply's text.
The schema¶
class TutorAnswer(BaseModel):
mechanism: str # two or three sentences, why the body does what it does
bedside: list[str] # three to five bullets, each ending in its citation marker
check: str | None = None # one question, or nothing
A citation marker ([[article:7]], [[section:7#abc]]) is ordinary text
inside these strings — the schema does not parse or validate it. That is
still enforce_citations's job, run on the string render() produces, the
same way it runs on a prompt-path reply. Nothing downstream of generation —
enforce_citations, the message store, the frontend — can tell which path
wrote an answer.
A model validator refuses two things: an empty mechanism, and more than
five bullets in bedside (blank bullets are dropped rather than counted).
Either one raises a ValueError, which Pydantic AI turns into a validation
error fed back to the model as a retry — the model gets a chance to fix its
own answer in the same run, before this code sees a second draft.
render() turns a TutorAnswer back into the same markdown the prompt path
has always produced: the mechanism paragraph, a blank line, **At the
bedside**, the bullets, and **Check yourself:** … when there is one.
The briefing¶
instructions_for(mode, sources) is ai_mode_service.build_prompt(sources,
mode) with one paragraph swapped out. Everything else — the persona, the
citation rule, what each mode (sourced / adjacent / open) says about
using the sources, and sources_block()'s formatting of the shortlist
itself — is reused whole, not re-derived. The only change is the paragraph
that told a prompt-path model how to write markdown headings; that no
longer applies; a paragraph describing the schema's fields replaces it.
Small talk (mode == "chat") never reaches the agent. A greeting has no
mechanism to explain, so it has no shape the schema fits, and the router
keeps it on the prompt path regardless of the engine setting.
The model¶
tutor_agent._agent() builds an OpenAIChatModel against the same
OpenAI-compatible proxy the rest of the app talks to — LITELLM_API_BASE
and the task's resolved API key, the same fallback chain ai_service
uses — via OpenAIProvider(base_url=..., api_key=...). Generation is pinned
the same way the prompt path pins it: temperature 0, a seed derived from the
question (so two people asking the same thing get the same answer), and
no_thinking(model_id) passed as extra_body to switch a DeepSeek model's
thinking off, unchanged from ai_service.
The switch¶
"agent" is the schema-backed path described above. "prompt" is the
free-text path that preceded it, left untouched — set AI_MODE_ENGINE=prompt
in the environment and the router never imports tutor_agent at all.
The router also falls back to the prompt path on its own, per request: if
tutor_agent.answer() raises anything — a bad response, a spent retry
budget, the proxy unreachable — that one request is answered by the prompt
path instead, with a warning logged, rather than surfacing as an error the
learner can do nothing about.
Running the tests¶
No network is used anywhere in the test suite; the model is stubbed with
Pydantic AI's own TestModel / FunctionModel, the same way the prompt
path's tests stub ai_service.achat.
backend/tests/test_tutor_agent.py alone:
DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests -p "test_tutor_agent.py" -v
backend/tests/test_ai_mode.py pins itself to AI_MODE_ENGINE=prompt in its
own fixture, so it exercises the prompt path regardless of the site-wide
default — it is not the place the agent engine is tested.
Dependency notes¶
pydantic-ai-slim[openai] is pinned in requirements.txt, not the full
pydantic-ai package — the model this app talks to is always reached over
an OpenAI-compatible endpoint, so the Anthropic, Google, Cohere, MCP and CLI
extras the full package pulls in buy nothing here.
pydantic-ai-slim needs pydantic>=2.12; pydantic and pydantic-settings
were bumped together with it (from 2.6.1 and 2.1.0) to versions confirmed to
import cleanly together, alongside the fastapi and sqlalchemy versions
already pinned.