awellis's picture
Deploy diagnostic-tool-spec
d2b9ff5
Raw
History Blame Contribute Delete
3.89 kB
"""Call the Anthropic API with the spec and student answer; return a structured diagnosis.
The Anthropic SDK is used directly (not via PydanticAI or similar) so the
API call is transparent. A workshop participant can read this file and see
exactly how `output_config.format` (via the SDK's Pydantic-aware
`messages.parse()` helper) constrains the model to emit JSON matching the
`DiagnosticResponse` schema.
"""
import os
import time
from dataclasses import dataclass
from anthropic import Anthropic
from dotenv import load_dotenv
from .models import DiagnosticResponse, Spec
load_dotenv()
MODEL = os.getenv("WORKSHOP_TOOL_MODEL", "claude-sonnet-4-6")
_client = Anthropic()
SYSTEM_PROMPT = (
"You are a pedagogical diagnostician. Given a teaching-task spec and a "
"student's answer, you identify which Skills and Knowledge Components "
"from the spec the answer demonstrates, which are missing, and which "
"Misconceptions the answer exhibits. You quote specific phrases from "
"the answer as evidence.\n\n"
"Constraints:\n"
"1. Only mark a Skill as 'present' if the answer demonstrably uses it.\n"
"2. Only mark a Misconception as 'detected' if the answer exhibits it; "
"use the exact name from the spec.\n"
"3. Evidence entries must quote specific phrases from the student's "
"answer in single quotes.\n"
"4. Match the language of the spec and answer (typically German).\n"
"5. Respond with a JSON object matching the required schema."
)
def build_user_prompt(spec: Spec, answer: str) -> str:
"""Render the spec and student answer into the user-message text sent to the API."""
skills_block = "\n".join(f"- {s}" for s in spec.skills_and_knowledge)
misconceptions_block = (
"\n".join(f"- {m}" for m in spec.misconceptions)
if spec.misconceptions
else "(none listed)"
)
return (
f"## Teaching task\n{spec.lernaufgabe}\n\n"
f"## Skills and Knowledge expected\n{skills_block}\n\n"
f"## Misconceptions to watch for\n{misconceptions_block}\n\n"
f"## Student's answer\n{answer.strip()}\n\n"
f"Diagnose the answer."
)
@dataclass
class DiagnosisResult:
"""The diagnosis plus call metadata the UI can display."""
response: DiagnosticResponse
model: str
latency_seconds: float
input_tokens: int | None
output_tokens: int | None
def diagnose(spec: Spec, student_answer: str) -> DiagnosticResponse:
"""Diagnose a student answer against a spec.
Returns only the parsed response. Existing callers and tests rely on this
shape; the UI calls `diagnose_with_meta` to also surface model/latency.
"""
return diagnose_with_meta(spec, student_answer).response
def diagnose_with_meta(spec: Spec, student_answer: str) -> DiagnosisResult:
"""Diagnose and return the response wrapped with model name and call timing."""
user_prompt = build_user_prompt(spec, student_answer)
start = time.perf_counter()
response = _client.messages.parse(
model=MODEL,
max_tokens=1500,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
output_format=DiagnosticResponse,
)
latency = time.perf_counter() - start
if response.parsed_output is None:
raise RuntimeError(
"The model did not return a parseable structured response. "
f"Stop reason: {response.stop_reason}; content: {response.content}"
)
usage = getattr(response, "usage", None)
input_tokens = getattr(usage, "input_tokens", None) if usage else None
output_tokens = getattr(usage, "output_tokens", None) if usage else None
return DiagnosisResult(
response=response.parsed_output,
model=MODEL,
latency_seconds=latency,
input_tokens=input_tokens,
output_tokens=output_tokens,
)