Add an AI Feature
Before you start
You need: a working API — Track 10 for .NET or Track 13 Article 11 for Python — and Article 07.
You also need: an API key from an LLM provider. Anthropic and OpenAI both have paid keys with small minimums; this feature costs a few rupees to build and test.
Time: about 90 minutes to read, then 3–4 hours to build.
Learning objective
Add one real AI feature to your project, handle its failure modes, and be able to defend every decision in it.
Topics
- Why this is worth four hours
- The feature: a draft report-card comment
- The privacy rule that comes first
- Calling the API from ASP.NET Core
- Calling the API from FastAPI
- Failure modes, and degrading properly
- Cost and latency, in real numbers
- Why a human edits before saving
Why this is worth four hours
Most fresher portfolios in 2026 contain a CRUD application. Yours will too — and it should, because CRUD done properly is what the job is.
One working AI feature makes yours different, and not because AI is fashionable. It is different because it forces you to handle things a CRUD screen never does: an external service that is slow, occasionally refuses, costs money per call, and returns text rather than a contract. Handling that well is a senior-ish skill demonstrated at fresher level.
It also gives you something specific to talk about when an interviewer asks about AI — which they now do.
What it is not: machine learning. You are calling somebody else's model over HTTP. That is honest and it is what the vast majority of "AI features" in real products are. Say it that way in an interview; claiming to have built a model would be found out in one question.
The feature: a draft report-card comment
A teacher enters marks for a class. Writing an individual comment for forty students takes an hour. The feature drafts one from the marks, and the teacher edits it before it is saved.
Input: Subject: Mathematics
Marks: 78 out of 100, class average 64
Attendance: 92%
Output: "Consistent performance in Mathematics this term, comfortably
above the class average. Attendance has been strong. With more
practice on problem-solving speed, a higher band is achievable."
Teacher edits it, then saves.
Why this use case and not a chatbot: it is bounded, the output is short, a wrong answer is visibly wrong to the teacher who reviews it, and nobody is harmed if the service is down — they write the comment themselves, as they do now.
That last property is what makes a feature safe to add as a fresher. Choose one where failure is an inconvenience, never a correctness problem.
The privacy rule that comes first
Never send real student data to an external API.
Send: marks, maximum marks, class average, attendance percentage,
subject name
Never send: student name, roll number, parent name, parent phone,
address, date of birth, photograph
This is the same rule Track 18 applies to what you paste into a prompt, and it applies harder here because this runs automatically for every student.
Student records are regulated personal data, and these are minors. India's DPDP Act places additional obligations on children's data; other jurisdictions have their own. The specifics vary, the obligation to not send it to a third-party service does not.
The feature does not need the name. "A student scored 78 out of 100 against a class average of 64" produces exactly as good a comment as one with a name in it. If your prompt contains a name, you added it for no benefit and took on real risk.
Build the request from a small, explicit object so this cannot happen by accident:
public sealed class CommentRequest
{
public string SubjectName { get; set; }
public int MarksObtained { get; set; }
public int MaxMarks { get; set; }
public decimal ClassAverage { get; set; }
public int AttendancePercentage { get; set; }
}
Never pass the Student entity itself. That is the same mistake as returning an entity from an endpoint — it carries fields the recipient should not have.
Calling the API from ASP.NET Core
The key goes in User Secrets. Right-click the project → Manage User Secrets:
{
"Anthropic": {
"ApiKey": "your-key-here",
"Model": "claude-sonnet-5"
}
}
Never in appsettings.json. That file is committed, and a committed key is a leaked key — deleting it later does not remove it from history. The only fix is revoking the key.
Register a typed HttpClient in Program.cs:
builder.Services.AddHttpClient<ICommentDraftService, CommentDraftService>(client =>
{
client.BaseAddress = new Uri("https://api.anthropic.com/");
client.Timeout = TimeSpan.FromSeconds(20);
});
A timeout is not optional. Without one, a slow provider holds your request thread until the default 100 seconds elapse, and a page that hangs for a minute and a half is worse than one that says "unavailable".
The service:
public sealed class CommentDraftService : ICommentDraftService
{
private readonly HttpClient _httpClient;
private readonly ILogger<CommentDraftService> _logger;
private readonly string _apiKey;
private readonly string _model;
public CommentDraftService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<CommentDraftService> logger)
{
_httpClient = httpClient;
_logger = logger;
_apiKey = configuration["Anthropic:ApiKey"];
_model = configuration["Anthropic:Model"];
}
public async Task<string> DraftCommentAsync(
CommentRequest request,
CancellationToken cancellationToken)
{
string prompt =
$"Write one encouraging report-card comment for a school student, " +
$"two sentences, suitable for a parent to read. " +
$"Subject: {request.SubjectName}. " +
$"Score: {request.MarksObtained} out of {request.MaxMarks}. " +
$"Class average: {request.ClassAverage:N0}. " +
$"Attendance: {request.AttendancePercentage}%. " +
$"Do not invent facts. Do not use a student name.";
var payload = new
{
model = _model,
max_tokens = 200,
messages = new[] { new { role = "user", content = prompt } }
};
using HttpRequestMessage message =
new HttpRequestMessage(HttpMethod.Post, "v1/messages");
message.Headers.Add("x-api-key", _apiKey);
message.Headers.Add("anthropic-version", "2023-06-01");
message.Content = JsonContent.Create(payload);
HttpResponseMessage response =
await _httpClient.SendAsync(message, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Comment draft failed with {StatusCode} for subject {Subject}",
response.StatusCode, request.SubjectName);
return null;
}
using Stream stream =
await response.Content.ReadAsStreamAsync(cancellationToken);
using JsonDocument document =
await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement
.GetProperty("content")[0]
.GetProperty("text")
.GetString();
}
}
Five things in that are deliberate:
CancellationTokenpassed through. If the teacher navigates away, the call stops. ASP.NET Core supplies the token; pass it down.- No student identifier anywhere in the prompt, and the prompt says so explicitly.
max_tokensis capped. You pay per token, and an unbounded response is an unbounded bill.- Returns
nullrather than throwing on a failed call. The caller degrades; see below. - The log records the status and the subject, not the prompt. Prompts can contain data you do not want in logs.
The endpoint:
[Authorize(Roles = "Teacher,Admin,Principal")]
[HttpPost("api/exam-results/{resultId:int}/draft-comment")]
public async Task<IActionResult> DraftComment(int resultId, CancellationToken cancellationToken)
{
int schoolId = int.Parse(User.FindFirst("schoolId").Value);
ExamResult result = await _examService.GetResultAsync(schoolId, resultId);
if (result == null)
{
return NotFound();
}
if (result.IsAbsent)
{
return Ok(new { comment = (string)null, reason = "Student was absent." });
}
string comment = await _commentService.DraftCommentAsync(
_mapper.ToCommentRequest(result), cancellationToken);
if (comment == null)
{
return Ok(new { comment = (string)null, reason = "Draft unavailable — please write the comment." });
}
return Ok(new { comment });
}
schoolId from the claim, [Authorize] with roles, and the absent case handled first — the same four rules as everywhere else. An AI feature is not exempt from them.
Note it returns 200 with a null comment, not an error, when the draft is unavailable. The teacher's screen still works; there is simply nothing pre-filled.
Calling the API from FastAPI
# settings.py — the key comes from the environment, never the source
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
anthropic_api_key: str
anthropic_model: str = "claude-sonnet-5"
class Config:
env_file = ".env" # in .gitignore, always
settings = Settings()
# comment_service.py
import httpx
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class CommentRequest:
subject_name: str
marks_obtained: int
max_marks: int
class_average: float
attendance_percentage: int
async def draft_comment(request: CommentRequest) -> str | None:
prompt = (
"Write one encouraging report-card comment for a school student, "
"two sentences, suitable for a parent to read. "
f"Subject: {request.subject_name}. "
f"Score: {request.marks_obtained} out of {request.max_marks}. "
f"Class average: {request.class_average:.0f}. "
f"Attendance: {request.attendance_percentage}%. "
"Do not invent facts. Do not use a student name."
)
payload = {
"model": settings.anthropic_model,
"max_tokens": 200,
"messages": [{"role": "user", "content": prompt}],
}
headers = {
"x-api-key": settings.anthropic_api_key,
"anthropic-version": "2023-06-01",
}
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.post(
"https://api.anthropic.com/v1/messages",
json=payload,
headers=headers,
)
except httpx.TimeoutException:
logger.warning("Comment draft timed out for subject %s", request.subject_name)
return None
except httpx.HTTPError:
logger.warning("Comment draft failed for subject %s", request.subject_name)
return None
if response.status_code != 200:
logger.warning(
"Comment draft returned %s for subject %s",
response.status_code, request.subject_name,
)
return None
return response.json()["content"][0]["text"]
# router.py
@router.post("/exam-results/{result_id}/draft-comment")
async def draft_result_comment(
result_id: int,
current_user: User = Depends(require_roles("Teacher", "Admin", "Principal")),
db: Session = Depends(get_db),
):
result = get_result(db, school_id=current_user.school_id, result_id=result_id)
if result is None:
raise HTTPException(status_code=404, detail="Result not found")
if result.is_absent:
return {"comment": None, "reason": "Student was absent."}
comment = await draft_comment(to_comment_request(result))
if comment is None:
return {"comment": None, "reason": "Draft unavailable — please write the comment."}
return {"comment": comment}
school_id comes from current_user, not the request. Same rule, different language.
Failure modes, and degrading properly
An external API fails in ways a database does not. Handle each.
| Failure | What happens | Your response |
|---|---|---|
| Timeout | Provider slow or unreachable | Return null; teacher writes it |
| 429 rate limited | Too many requests | Return null; do not retry in a loop |
| 401 | Key wrong, revoked, or out of credit | Log it loudly — this needs a human |
| 500 from the provider | Their problem | Return null |
| Refusal | Model declines to answer | Treat as no draft |
| Wrong shape | Response is not what you parsed | Catch it; do not let it 500 your API |
| Empty response | Valid call, nothing useful | Treat as no draft |
The rule for all of them: the feature degrades, the page still works.
Draft available → textarea pre-filled, teacher edits, saves
Draft unavailable → empty textarea and a quiet note, teacher writes, saves
Never block the save on the AI call. If drafting fails, the teacher must still be able to enter a comment — the feature is assistive. A feature that can prevent a teacher doing their job is a worse feature than no feature.
Do not retry automatically more than once. A retry loop against a rate-limited endpoint turns a small problem into a bill.
Cost and latency, in real numbers
Know these before you build, and be able to state them in an interview.
| Roughly | |
|---|---|
| Prompt size | ~120 tokens |
| Response size | ~80 tokens, capped at 200 |
| Latency | 1–3 seconds |
| Cost per draft | Fractions of a rupee |
| A class of 40 | Well under a rupee |
| Building and testing this feature | A few rupees total |
Latency is why this is a button, not automatic. Two seconds is fine when a teacher clicks "Suggest"; it is not fine multiplied by forty on a page load.
Cap max_tokens always. It is the only thing standing between a bug and an unbounded bill.
Log usage per call if you take this further. "How much does this cost to run?" is a question a senior developer will ask you about it, and having the number is a good answer.
Why a human edits before saving
The design decision an interviewer will probe.
The model does not know the student. It has marks and attendance. It does not know they were ill in October, or that this is a large improvement from last term. The teacher does.
A report card is read by a parent. A generated sentence that is subtly wrong — or generic in a way that reveals it was generated — damages trust in the school.
Accountability stays with a person. If a comment is inappropriate, "the AI wrote it" is not an answer the school can give a parent. The teacher who saved it is accountable, and the workflow makes that explicit by requiring the edit.
This is the general principle, and it is worth being able to state: AI drafts, humans decide. Any feature where the AI output goes straight to a person who is affected, with nobody in between, needs a very good reason.
Common mistakes
- Sending student names or roll numbers in the prompt
- The API key in
appsettings.jsonor committed.env - No timeout, so a slow provider hangs the page
- No
max_tokens, so cost is unbounded - Letting a failed AI call break the save
- Retrying in a loop against a rate limit
- Logging the whole prompt, including data
- Skipping
[Authorize]because "it's only a draft" - Auto-saving the generated comment
- Calling it machine learning in an interview
Practice
- Build the feature in your stack. One endpoint, one service, one button.
- Put the key in User Secrets or
.env. Confirmgit statusshows nothing to commit. - Print the exact prompt you send. Check it contains no name, roll number or phone.
- Test with the API key removed. Confirm the page still works and the teacher can type.
- Set the timeout to 1 millisecond. Confirm you get the graceful message, not a 500.
- Send a request for an absent student. Confirm it returns without calling the API at all.
- Call the endpoint as a Student role. Confirm 403.
- Measure the latency of ten calls. Write down the average.
- Work out the cost of drafting comments for a class of 40, from the provider's pricing.
- Write three sentences explaining why the teacher edits before saving. This is an interview answer.
Exercises 4 and 5 are the ones that make it a real feature rather than a demo.
You can now
- Call an LLM API from ASP.NET Core or FastAPI
- Keep the key out of source control
- Send the minimum data, never a student identifier
- Handle timeout, rate limit, refusal and malformed response
- Degrade so the page works when the service does not
- State the cost and latency in real numbers
- Explain why a human edits before saving
- Describe the feature honestly as an API call, not machine learning
Review questions
- What may be sent to the API, and what may never be?
- Why does the endpoint return 200 with a null comment rather than an error?
- What are the two limits that protect you from an unbounded bill?
- Why must a teacher edit the comment before it is saved?
Next: Projects and portfolio