Skip to content

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

# backend/app/config.py
AI_MODE_ENGINE: str = "agent"   # "agent" | "prompt"

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

DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests

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.