অধ্যায় 4 · আপনার প্রথম ব্যাকএন্ড
Pydantic মডেল, ভ্যালিডেশন ও এরর রেসপন্স
- পৃষ্ঠা 18 / 22
- 16 মিনিট পড়া
API হলো একটা চুক্তি: "আমাকে এই আকারের ডেটা পাঠান, আমি ওই আকারের ডেটা ফেরত দেব"। আগের পাতায় ছোট একটা Pydantic মডেল (SentimentRequest) দিয়েই একটা অনুপস্থিত ফিল্ড বা ভুল টাইপ ঠেকানো গিয়েছিল। বাস্তব রিকোয়েস্টে আরও অনেক কিছু লাগে: নাম হতে হবে ৩ থেকে ৪০ অক্ষরের, temperature থাকতে হবে ০ থেকে ২-এর মধ্যে, ট্যাগ সর্বোচ্চ ৫টা, ইমেইল বৈধ, আর টেমপ্লেটে একটা প্লেসহোল্ডার থাকতেই হবে। রেসপন্সেরও নিয়ম দরকার, যাতে ভেতরের কোনো ফিল্ড (মালিকের ইমেইল, খরচ, API কি) ভুল করে বাইরে চলে না যায়।
এই পাতায় আপনি একটা ছোট প্রম্পট লাইব্রেরি API বানাবেন। AI টিমগুলো প্রম্পট টেমপ্লেট জমিয়ে রাখতে ও বারবার ব্যবহার করতে ঠিক এমন অভ্যন্তরীণ সার্ভিসই চালায়। ডেটার আকার বর্ণনা করবেন Pydantic মডেল দিয়ে, প্রতিটা ফিল্ডে সীমা বসাবেন, ভ্যালিডেশন এরর ঠিকমতো পড়বেন, রেসপন্স ফিল্টার করবেন, আর তৈরি, পড়া, মোছা ও "পাওয়া যায়নি"-র জন্য ঠিক স্ট্যাটাস কোড পাঠাবেন।
যা শিখবেন
- Pydantic মডেল কীভাবে বানাতে হয়, ডেটা থেকে অবজেক্ট তৈরি করতে হয়, আর আবার dict ও JSON-এ ফেরাতে হয়।
Fieldদিয়ে সীমা (দৈর্ঘ্য, পরিসর, প্যাটার্ন),Literalদিয়ে নির্দিষ্ট বিকল্প, আরfield_validatorদিয়ে নিজের নিয়ম বসানো।- 422 ভ্যালিডেশন এরর পড়া:
type,loc,msg,inputওctx। - রেসপন্স মডেল কীভাবে ফিল্ড লুকায়, আর
status_codeওHTTPExceptionদিয়ে 201, 204, 404 ও 409 পাঠানো। - প্রথম প্রজেক্টের একটা সুন্দর কাঠামো:
main.pyওschemas.pyসহ একটাappপ্যাকেজ।
প্রজেক্ট সেট আপ করুন
আপনার প্রথম FastAPI অ্যাপ পাতার মতোই একটা নতুন uv প্রজেক্ট। এবার কোড একটা main.py-তে না থেকে app নামের প্যাকেজে থাকবে; কেন, সেটা পাতার শেষে দেখবেন।
mkdir -p ~/projects
cd ~/projects
uv init -q --no-package bitbyte-prompts
cd bitbyte-prompts
uv add -q "fastapi[standard]"
rm main.py
mkdir app
touch app/__init__.py
ls -A app__init__.pypip দিয়ে করতে চাইলে: python -m venv .venv, source .venv/bin/activate, pip install "fastapi[standard]", তারপর একই mkdir ও touch। ফাঁকা app/__init__.py ফোল্ডারটাকে Python প্যাকেজ হিসেবে চিহ্নিত করে, ফলে আপনি from app.schemas import ... লিখতে পারেন।
Pydantic মডেল
Pydantic মডেল হলো BaseModel থেকে উত্তরাধিকার পাওয়া একটা ক্লাস, যার ফিল্ডগুলো টাইপ হিন্টসহ লেখা থাকে। মডেল তৈরির সময়ই ডেটা যাচাই হয়: মান পরীক্ষা করা হয় আর নিরাপদ হলে রূপান্তরও করা হয়। আপনার API-র ডেটার আকার, অর্থাৎ স্কিমা, আলাদা ফাইলে রাখুন:
# file: app/schemas.py
"""The shapes of the data the prompt library accepts and returns."""
from typing import Annotated, Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
class PromptBase(BaseModel):
name: Annotated[str, Field(min_length=3, max_length=40, pattern=r"^[a-z0-9-]+$")]
template: Annotated[str, Field(min_length=10, max_length=2000)]
model: Literal["bitbyte-mini", "bitbyte-pro"] = "bitbyte-mini"
temperature: Annotated[float, Field(ge=0, le=2)] = 0.7
tags: Annotated[list[str], Field(max_length=5)] = []
@field_validator("template")
@classmethod
def must_have_question(cls, value: str) -> str:
if "{question}" not in value:
raise ValueError("template must contain the {question} placeholder")
return value
class PromptCreate(PromptBase):
"""What a client sends to create a prompt."""
owner_email: EmailStr
class PromptPublic(PromptBase):
"""What the API returns: no owner_email."""
id: int
class RenderRequest(BaseModel):
question: Annotated[str, Field(min_length=1, max_length=500)]
class RenderResponse(BaseModel):
prompt_id: int
model: str
temperature: float
text: strপ্রথমে কোনো ওয়েব সার্ভার ছাড়াই Python-এ মডেলগুলো চালিয়ে দেখুন। একটা বৈধ প্রম্পট:
from app.schemas import PromptCreate, PromptPublic
prompt = PromptCreate(
name="course-helper",
template="You are a friendly tutor. Answer briefly: {question}",
temperature="0.2",
owner_email="nadia@example.com",
)
print(repr(prompt.temperature), prompt.model, prompt.tags)
print(prompt.model_dump())
print(prompt.model_dump_json(exclude={"owner_email"}))0.2 bitbyte-mini []
{'name': 'course-helper', 'template': 'You are a friendly tutor. Answer briefly: {question}', 'model': 'bitbyte-mini', 'temperature': 0.2, 'tags': [], 'owner_email': 'nadia@example.com'}
{"name":"course-helper","template":"You are a friendly tutor. Answer briefly: {question}","model":"bitbyte-mini","temperature":0.2,"tags":[]}খেয়াল করুন:
"0.2"(স্ট্রিং) হয়ে গেছে float0.2। অর্থ পরিষ্কার হলে Pydantic রূপান্তর করে দেয়, কোয়েরি স্ট্রিং ও ফর্ম ডেটার জন্য এটা কাজের। কিন্তু"warm"কখনো সংখ্যা হবে না।- যে ফিল্ড দেননি সেগুলো ডিফল্ট মান পেয়েছে:
modelহয়েছে"bitbyte-mini", আরtagsখালি লিস্ট (প্রতিটা অবজেক্টের জন্য আলাদা নতুন লিস্ট, তাই সাধারণ Python-এর "শেয়ার করা ডিফল্ট" ফাঁদ এখানে নেই)। model_dump()দেয় dict,model_dump_json()দেয় JSON স্ট্রিং; দুটোতেই কিছু ফিল্ড বাদ দেওয়া যায়। Pydantic v2-তে এই নামগুলো চলে; v1-এর পুরোনো টিউটোরিয়ালের.dict()ও.json()এখন বাতিলের পথে।
এবার এমন একটা প্রম্পট, যে একসঙ্গে কয়েকটা নিয়ম ভাঙে:
from pydantic import ValidationError
try:
PromptCreate(
name="Course Helper!",
template="Answer this.",
model="gpt-x",
temperature=3,
owner_email="nadia-at-example",
)
except ValidationError as exc:
print(exc.error_count(), "errors")
for error in exc.errors():
print(error["loc"], "|", error["type"], "|", error["msg"])5 errors
('name',) | string_pattern_mismatch | String should match pattern '^[a-z0-9-]+$'
('template',) | value_error | Value error, template must contain the {question} placeholder
('model',) | literal_error | Input should be 'bitbyte-mini' or 'bitbyte-pro'
('temperature',) | less_than_equal | Input should be less than or equal to 2
('owner_email',) | value_error | value is not a valid email address: An email address must have an @-sign.Pydantic প্রথম সমস্যাতেই থামে না: সব ফিল্ড পরীক্ষা করে সব এরর একসঙ্গে জানায়, প্রতিটার সঙ্গে থাকে অবস্থান, যন্ত্রপাঠ্য একটা type আর মানুষের জন্য বার্তা। FastAPI ঠিক এটাই ক্লায়েন্টকে ফেরত পাঠাবে। নিজের নিয়মের বার্তাটাও লক্ষ করুন: আপনার ValueError-এর লেখা Value error,-এর পরে বসেছে।
Field-এর সীমা
সীমা বসে Annotated[type, Field(...)]-এর ভেতরে। সবচেয়ে বেশি যেগুলো কাজে লাগবে:
| সীমা | কিসের ওপর কাজ করে | schemas.py-এর উদাহরণ | ভাঙলে এররের type |
|---|---|---|---|
min_length, max_length | স্ট্রিং, লিস্ট | name ৩ থেকে ৪০ অক্ষর; tags সর্বোচ্চ ৫টা | string_too_short, too_long |
ge, gt, le, lt | সংখ্যা | temperature ০ থেকে ২ | less_than_equal |
pattern | স্ট্রিং | name-এ শুধু ছোট হাতের অক্ষর, অঙ্ক ও - | string_pattern_mismatch |
Literal[...] | যেকোনো কিছু | model দুটো নামের একটা হতে হবে | literal_error |
| বিশেষ টাইপ | স্ট্রিং | EmailStr (আরও আছে HttpUrl, UUID, datetime) | value_error |
@field_validator | যেকোনো কিছু | template-এ {question} থাকতে হবে | value_error |
AI এন্ডপয়েন্টে এই সীমাগুলো শোভা বাড়ানোর জন্য নয়। লেখার ওপর max_length একটা রিকোয়েস্ট সর্বোচ্চ কত টোকেনের খরচ ঘটাতে পারবে তা বেঁধে দেয়; temperature-এর পরিসর এমন মান আটকায় যা প্রোভাইডার নিজেই ফিরিয়ে দিত; মডেলের নামে Literal ক্লায়েন্টকে আপনার সবচেয়ে দামি মডেল বেছে নিতে দেয় না।
API
এবার এন্ডপয়েন্টে মডেলগুলো ব্যবহার করুন। ডেটা মেমরিতে একটা dict-এ থাকবে, শেখার জন্য সেটাই যথেষ্ট (সার্ভার রিস্টার্ট হলেই তা খালি হয়ে যায়; ডেটাবেস আসবে অ্যাডভান্সড টিউটোরিয়ালে)।
# file: app/main.py
"""BitByte Prompt Library: store prompt templates and fill them in."""
from itertools import count
from typing import Annotated
from fastapi import FastAPI, HTTPException, Path, status
from app.schemas import PromptCreate, PromptPublic, RenderRequest, RenderResponse
app = FastAPI(title="BitByte Prompt Library", version="0.1.0")
PROMPTS: dict[int, dict] = {} # id -> সংরক্ষিত রেকর্ড (owner_email সহ)
_next_id = count(1)
PromptId = Annotated[int, Path(ge=1, description="The prompt's id")]
def find_prompt(prompt_id: int) -> dict:
record = PROMPTS.get(prompt_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Prompt {prompt_id} not found")
return record
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/prompts", status_code=status.HTTP_201_CREATED)
def create_prompt(prompt: PromptCreate) -> PromptPublic:
if any(p["name"] == prompt.name for p in PROMPTS.values()):
raise HTTPException(status_code=409, detail=f"A prompt named {prompt.name!r} already exists")
record = {"id": next(_next_id), **prompt.model_dump()}
PROMPTS[record["id"]] = record
return record
@app.get("/prompts")
def list_prompts(tag: str | None = None) -> list[PromptPublic]:
return [p for p in PROMPTS.values() if tag is None or tag in p["tags"]]
@app.get("/prompts/{prompt_id}")
def get_prompt(prompt_id: PromptId) -> PromptPublic:
return find_prompt(prompt_id)
@app.delete("/prompts/{prompt_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_prompt(prompt_id: PromptId) -> None:
find_prompt(prompt_id)
del PROMPTS[prompt_id]
@app.post("/prompts/{prompt_id}/render")
def render_prompt(prompt_id: PromptId, body: RenderRequest) -> RenderResponse:
record = find_prompt(prompt_id)
return RenderResponse(
prompt_id=prompt_id,
model=record["model"],
temperature=record["temperature"],
text=record["template"].replace("{question}", body.question),
)এখানে তিনটা জিনিস নতুন। রিটার্ন টাইপ অ্যানোটেশন (-> PromptPublic) FastAPI-কে বলে ফাংশনের ফেরত দেওয়া মান যাচাই ও ফিল্টার করতে। ডেকোরেটরে status_code= সফল রেসপন্সের স্ট্যাটাস ঠিক করে। আর PromptId হলো একটা অ্যানোটেটেড টাইপের পুনর্ব্যবহারযোগ্য নাম, ফলে একই নিয়ম একবারই লিখতে হয়। সার্ভার চালু করুন; খেয়াল করুন পাথ এখন app/main.py:
uv run fastapi run app/main.py --port 8000 > server.log 2>&1 &
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}তৈরি করা: 201 আর ফিল্টার করা রেসপন্স
একটা প্রম্পট তৈরি করুন। -w বডির পরে স্ট্যাটাস কোড ছাপে:
curl -s -w "\nstatus: %{http_code}\n" -X POST http://localhost:8000/prompts \
-H "Content-Type: application/json" \
-d '{"name": "course-helper",
"template": "You are a friendly tutor. Answer briefly: {question}",
"tags": ["tutor", "short"],
"owner_email": "nadia@example.com"}'{"name":"course-helper","template":"You are a friendly tutor. Answer briefly: {question}","model":"bitbyte-mini","temperature":0.7,"tags":["tutor","short"],"id":1}
status: 201স্ট্যাটাস 201 Created, ঠিক যেমনটা ঘোষণা করা ছিল। রেসপন্সে id আছে, কিন্তু owner_email নেই, অথচ সংরক্ষিত রেকর্ডে সেটা আছে এবং ফাংশন সেই রেকর্ডটাই ফেরত দিয়েছিল। রিটার্ন টাইপ PromptPublic-এ ফিল্ডটা নেই, তাই FastAPI সেটা ফেলে দিয়েছে। রেসপন্স বানানোর নিরাপদ উপায় এটাই: কী কী বাইরে যেতে পারে তার তালিকা লিখুন, কী কী যেতে পারবে না তা মনে রাখার চেষ্টা করবেন না। (ডেকোরেটরে পুরোনো ধাঁচের response_model=PromptPublic আর্গুমেন্টও দেখতে পারেন; কাজ একই।)
দ্বিতীয় একটা প্রম্পট যোগ করে তারপর তালিকা ও একটা প্রম্পট আনুন:
curl -s -X POST http://localhost:8000/prompts -H "Content-Type: application/json" \
-d '{"name": "bangla-summary", "template": "Summarise in simple Bangla: {question}",
"model": "bitbyte-pro", "temperature": 0.3, "tags": ["summary"], "owner_email": "rafi@example.com"}'
echo
curl -s "http://localhost:8000/prompts?tag=tutor"
echo
curl -s http://localhost:8000/prompts/2{"name":"bangla-summary","template":"Summarise in simple Bangla: {question}","model":"bitbyte-pro","temperature":0.3,"tags":["summary"],"id":2}
[{"name":"course-helper","template":"You are a friendly tutor. Answer briefly: {question}","model":"bitbyte-mini","temperature":0.7,"tags":["tutor","short"],"id":1}]
{"name":"bangla-summary","template":"Summarise in simple Bangla: {question}","model":"bitbyte-pro","temperature":0.3,"tags":["summary"],"id":2}ভ্যালিডেশন এরর পড়া
Python-এ যে খারাপ ডেটা দিয়েছিলেন, ঠিক সেটাই পাঠান। এবার রেসপন্সটা python -m json.tool-এ পাইপ করুন যাতে পড়তে সুবিধা হয়:
curl -s -X POST http://localhost:8000/prompts -H "Content-Type: application/json" \
-d '{"name": "Course Helper!", "template": "Answer this.", "temperature": 3, "owner_email": "nadia@example.com"}' \
| python -m json.tool{
"detail": [
{
"type": "string_pattern_mismatch",
"loc": [
"body",
"name"
],
"msg": "String should match pattern '^[a-z0-9-]+$'",
"input": "Course Helper!",
"ctx": {
"pattern": "^[a-z0-9-]+$"
}
},
{
"type": "value_error",
"loc": [
"body",
"template"
],
"msg": "Value error, template must contain the {question} placeholder",
"input": "Answer this.",
"ctx": {
"error": {}
}
},
{
"type": "less_than_equal",
"loc": [
"body",
"temperature"
],
"msg": "Input should be less than or equal to 2",
"input": 3,
"ctx": {
"le": 2.0
}
}
]
}স্ট্যাটাস 422, আর detail-এ প্রতিটা সমস্যার জন্য একটা করে এন্ট্রি আছে। প্রতিটা এন্ট্রিতে একই কি:
| কি | অর্থ | কীসে কাজে লাগে |
|---|---|---|
type | স্থির, যন্ত্রপাঠ্য এরর কোড | ক্লায়েন্ট কোডে এরর অনুযায়ী আলাদা পথ নেওয়া, বার্তা অনুবাদ করা |
loc | কোথায়: body, query, path বা header, তারপর ফিল্ড (লিস্ট হলে ইনডেক্সও) | ফর্মের সঠিক ফিল্ড হাইলাইট করা |
msg | মানুষের জন্য বার্তা | ডেভেলপার বা ব্যবহারকারীকে দেখানো |
input | যে মানটা বাতিল হয়েছে | ডিবাগিং (সাবধান: ক্লায়েন্ট যা পাঠিয়েছে সেটাই ফেরত আসে) |
ctx | নিয়মের সেটিং, যেমন {"le": 2.0} | নিজের বার্তা বানানো |
আপনার এন্ডপয়েন্টের কোড একবারও চলেনি। ভ্যালিডেশন হলো প্রথম দেয়াল: create_prompt চলার সময় prompt যে ঠিক আছে সেটা নিশ্চিত। ক্লায়েন্টের আরও দুটো পুরোনো ভুল:
curl -s -X POST http://localhost:8000/prompts -H "Content-Type: application/json" \
-d '{"name": "broken", "template": "Answer: {question}", "owner_email": "x@example.com",' \
| python -m json.tool
curl -s -X POST http://localhost:8000/prompts -H "Content-Type: application/json" \
-d '{"name": "too-many-tags", "template": "Answer: {question}", "owner_email": "x@example.com",
"tags": ["a", "b", "c", "d", "e", "f"]}' \
| python -m json.tool{
"detail": [
{
"type": "json_invalid",
"loc": [
"body",
84
],
"msg": "JSON decode error",
"input": {},
"ctx": {
"error": "Expecting property name enclosed in double quotes"
}
}
]
}
{
"detail": [
{
"type": "too_long",
"loc": [
"body",
"tags"
],
"msg": "List should have at most 5 items after validation, not 6",
"input": [
"a",
"b",
"c",
"d",
"e",
"f"
],
"ctx": {
"field_type": "List",
"max_length": 5,
"actual_length": 6
}
}
]
}ভাঙা JSON ধরা পড়ে json_invalid হিসেবে, সঙ্গে থাকে অক্ষরের অবস্থান; আর বেশি লম্বা লিস্টের এররে তার আসল দৈর্ঘ্য থাকে। দুই ক্ষেত্রেই ক্লায়েন্ট জানে ঠিক কী ঠিক করতে হবে।
স্ট্যাটাস কোড ও HTTPException
ভ্যালিডেশন বলে "ডেটার গড়ন ভুল"। বাকি সবকিছু, যেমন "এটা নেই" বা "অন্য কিছুর সঙ্গে সংঘাত", আপনার কোডের সিদ্ধান্ত, যা HTTPException দিয়ে জানানো হয়:
curl -s -w "\nstatus: %{http_code}\n" http://localhost:8000/prompts/99
curl -s -w "\nstatus: %{http_code}\n" -X POST http://localhost:8000/prompts -H "Content-Type: application/json" \
-d '{"name": "course-helper", "template": "Another: {question}", "owner_email": "mitu@example.com"}'
curl -s -w "status: %{http_code}\n" -X DELETE http://localhost:8000/prompts/2
curl -s -w "\nstatus: %{http_code}\n" -X DELETE http://localhost:8000/prompts/2{"detail":"Prompt 99 not found"}
status: 404
{"detail":"A prompt named 'course-helper' already exists"}
status: 409
status: 204
{"detail":"Prompt 2 not found"}
status: 404প্রথম DELETE-এর স্ট্যাটাসের আগে কিছুই ছাপেনি: 204 No Content-এর মানেই বডি নেই। দ্বিতীয়টা 404, কারণ প্রম্পটটা ততক্ষণে মুছে গেছে। এই API যে স্ট্যাটাসগুলো ব্যবহার করে, আর প্রতিটা কে পাঠায়:
| স্ট্যাটাস | কখন | কে পাঠায় |
|---|---|---|
200 OK | সফল পড়া বা রেন্ডার | সাধারণ রিটার্নে FastAPI-র ডিফল্ট |
201 Created | প্রম্পট তৈরি হয়েছে | ডেকোরেটরে status_code=status.HTTP_201_CREATED |
204 No Content | প্রম্পট মোছা হয়েছে | status_code=status.HTTP_204_NO_CONTENT, কিছু রিটার্ন নেই |
404 Not Found | অচেনা প্রম্পট আইডি | find_prompt-এ raise HTTPException(status_code=404, ...) |
409 Conflict | নামটা আগেই নেওয়া | raise HTTPException(status_code=409, ...) |
422 Unprocessable Content | রিকোয়েস্টের ডেটা স্কিমা ভাঙে | FastAPI ও Pydantic, স্বয়ংক্রিয়ভাবে |
status.HTTP_201_CREATED আসলে 201 সংখ্যাটারই একটা পড়ার মতো নাম; দুই রকমেই কাজ হয়। find_prompt-এর মতো সহায়ক ফাংশনের ভেতরে raise করলেও চলে, কলের যেকোনো স্তর থেকে: এক্সেপশন উপরে উঠে যায় আর FastAPI সেটাকে রেসপন্সে বদলে দেয়। তাই প্রম্পট দরকার এমন প্রতিটা এন্ডপয়েন্ট এক লাইনেই একই 404 পায়।
সব একসঙ্গে: প্রম্পট রেন্ডার করা
শেষ এন্ডপয়েন্টটা LLM অ্যাপের জন্য দরকারি একটা কাজ করে: সংরক্ষিত টেমপ্লেটে ব্যবহারকারীর প্রশ্ন বসিয়ে মডেলকে যা পাঠাতেন সেটা ফেরত দেয়, সঙ্গে মডেলের সেটিংও। এর ইনপুট ও আউটপুট দুটোই মডেল:
import requests
BASE = "http://localhost:8000"
r = requests.post(f"{BASE}/prompts/1/render", json={"question": "What is an API?"}, timeout=5)
print(r.status_code, r.json())
r = requests.post(f"{BASE}/prompts/1/render", json={"question": ""}, timeout=5)
print(r.status_code, r.json()["detail"][0]["msg"])200 {'prompt_id': 1, 'model': 'bitbyte-mini', 'temperature': 0.7, 'text': 'You are a friendly tutor. Answer briefly: What is an API?'}
422 String should have at least 1 characterআসল LLM ব্যাকএন্ডে পরের লাইনেই text, model ও temperature প্রোভাইডারকে পাঠানো হতো। প্রশ্নটা যাচাই করা (১ থেকে ৫০০ অক্ষর) হয় বলে খালি প্রশ্ন বা ৫০ পাতার পেস্ট কখনো টাকা খরচ হয় এমন API পর্যন্ত পৌঁছায় না।
প্রজেক্ট কাঠামোর মূল কথা
প্রজেক্টটা এখন দেখতে কেমন, আর বড় হলে কোথায় কী যাবে:
bitbyte-prompts/
├── app/
│ ├── __init__.py makes "app" a package
│ ├── main.py the FastAPI app and its endpoints
│ └── schemas.py Pydantic models: what goes in and out
├── tests/ tests (next page)
├── .gitignore .venv, __pycache__, .env ...
├── .python-version
├── pyproject.toml dependencies (uv) - or requirements.txt (pip)
├── uv.lock
└── README.mdনিয়মটা হলো: HTTP স্তর (পাথ, স্ট্যাটাস কোড) আলাদা রাখুন ডেটার আকার (স্কিমা) থেকে, আর পরে ব্যবসায়িক যুক্তি (সার্ভিস) ও ডেটা অ্যাক্সেস (ডেটাবেস স্তর) থেকেও। তাহলে প্রতিটা ফাইলের বদলানোর কারণ একটাই, আর টেস্ট নিজের দরকারি অংশটুকুই ইমপোর্ট করতে পারে। অ্যাডভান্সড টিউটোরিয়ালে রাউটার দিয়ে main.py আরও ভাগ করা হবে। দুটো ব্যবহারিক কথা: অ্যাপ চালান fastapi dev app/main.py (বা fastapi run app/main.py) দিয়ে, আর ইমপোর্ট সবসময় প্যাকেজের নাম ধরে করুন, from app.schemas import ..., যাতে সার্ভার, টেস্ট ও স্ক্রিপ্ট সব জায়গায় ইমপোর্ট একই রকম চলে।
আসল AI কাজে এটা কোথায় দেখবেন
- মডেলের ইনপুট: প্রেডিকশন এন্ডপয়েন্টের Pydantic মডেল প্রতিটা ফিচারের টাইপ ও পরিসর লিখে রাখে, ফলে
age: -4বা অনুপস্থিত কলামসহ রিকোয়েস্ট মডেল পর্যন্ত যায় না। - LLM রিকোয়েস্টের সীমা: বার্তার ওপর
max_length, বার্তার সংখ্যার সীমা আর অনুমোদিত মডেলেরLiteralতালিকা খরচ ও অপব্যবহার নিয়ন্ত্রণে রাখে। - কাঠামোবদ্ধ LLM আউটপুট: LLM যে JSON ফেরত দেয় তা যাচাইয়ে একই Pydantic মডেল কাজ করে; OpenAI ও Anthropic-এর SDK এবং অনেক এজেন্ট ফ্রেমওয়ার্ক কাঙ্ক্ষিত আউটপুট বোঝাতে Pydantic মডেল নেয়।
- নিরাপদ রেসপন্স: "তৈরি" ও "পাবলিক" আলাদা মডেল রাখলে ভেতরের ফিল্ড (মালিক, খরচ, এমবেডিং, কি) API রেসপন্সে ঢোকে না।
সাধারণ ভুল
- রেসপন্স মডেল ছাড়া সংরক্ষিত রেকর্ড সরাসরি ফেরত দেওয়া। ইমেইল বা ভেতরের নোটসহ রেকর্ডের সব ফিল্ড বাইরে চলে যায়। সমাধান: প্রতিটা এন্ডপয়েন্টে এমন রিটার্ন টাইপ দিন (
-> PromptPublic) যাতে শুধু পাবলিক ফিল্ড আছে। - এরর 200 স্ট্যাটাসে পাঠানো।
return {"error": "not found"}ক্লায়েন্ট ও মনিটরিং দুটোই ভেঙে দেয়। সমাধান:raise HTTPException(status_code=404, detail=...)। - এন্ডপয়েন্টের ভেতরে হাতে ভ্যালিডেশন করা।
if len(body.name) < 3: ...আসলেField-এর কাজেরই নকল, ভুলে যাওয়া সহজ, আর/docs-এও দেখা যায় না। সমাধান: নিয়ম মডেলে রাখুন। - পুরোনো টিউটোরিয়ালের Pydantic v1 কোড ব্যবহার করা।
.dict(),.json(),@validatorওclass Configসবই v1। সমাধান:model_dump(),model_dump_json(),@field_validatorওmodel_config। - 204-এ বডি পাঠানো। 204 রেসপন্সে বডি থাকতে নেই। সমাধান:
status_code=204দিলে কিছু রিটার্ন করবেন না (-> None)।
নিজে চেষ্টা করুন
- সহজ: AI-র উত্তরে রেটিং দেওয়ার জন্য
FeedbackInমডেল লিখুন:answer_id(পূর্ণসংখ্যা, অন্তত ১),rating(১ থেকে ৫) আর ঐচ্ছিকcomment(সর্বোচ্চ ২৮০ অক্ষর, ডিফল্টNone)। একটা বৈধ অবজেক্ট বানান আর একটাrating=6দিয়ে, তারপর এররেরtypeছাপুন। - মাঝারি:
PUT /prompts/{prompt_id}যোগ করুন, যা নতুনPromptCreateবডি দিয়ে প্রম্পট প্রতিস্থাপন করবে, আইডি অপরিবর্তিত রাখবে আরPromptPublicফেরত দেবে। অচেনা আইডি হলে 404 দিতে হবে। (উত্তরে এটা আলাদা দ্বিতীয় ফাইল থেকে যোগ করা হয়েছে, আর আগের পাতার মতো 8001 পোর্টে চলে।) - কঠিন: ট্যাগ এলোমেলো: ক্লায়েন্ট পাঠায়
["Tutor", "tutor ", "short"]। এমন একটা মডেল লিখুন যারfield_validatorফাঁকা অংশ ছেঁটে, ছোট হাতের করে, ডুপ্লিকেট বাদ দেয় কিন্তু প্রথম ক্রম ঠিক রাখে, যাতে লিস্টটা হয়["tutor", "short"]। ট্যাগ প্রতিটা এন্ডপয়েন্টে পরিষ্কার করার চেয়ে মডেলে পরিষ্কার করা ভালো কেন?
উত্তর
from typing import Annotated
from pydantic import BaseModel, Field, ValidationError
class FeedbackIn(BaseModel):
answer_id: Annotated[int, Field(ge=1)]
rating: Annotated[int, Field(ge=1, le=5)]
comment: Annotated[str | None, Field(max_length=280)] = None
print(FeedbackIn(answer_id=7, rating=5, comment="Clear and correct"))
try:
FeedbackIn(answer_id=7, rating=6)
except ValidationError as exc:
print(exc.errors()[0]["type"])answer_id=7 rating=5 comment='Clear and correct'
less_than_equal# file: answers.py
from app.main import PROMPTS, PromptId, app, find_prompt
from app.schemas import PromptCreate, PromptPublic
@app.put("/prompts/{prompt_id}")
def replace_prompt(prompt_id: PromptId, prompt: PromptCreate) -> PromptPublic:
find_prompt(prompt_id)
PROMPTS[prompt_id] = {"id": prompt_id, **prompt.model_dump()}
return PROMPTS[prompt_id]uv run fastapi run answers.py --port 8001 > answers.log 2>&1 &
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8001/health > /dev/null
curl -s -X POST http://localhost:8001/prompts -H "Content-Type: application/json" \
-d '{"name": "explain", "template": "Explain simply: {question}", "owner_email": "nadia@example.com"}'
echo
curl -s -X PUT http://localhost:8001/prompts/1 -H "Content-Type: application/json" \
-d '{"name": "explain", "template": "Explain like I am new to AI: {question}", "temperature": 0.4, "owner_email": "nadia@example.com"}'
echo
curl -s -X PUT http://localhost:8001/prompts/5 -H "Content-Type: application/json" \
-d '{"name": "ghost", "template": "Nobody: {question}", "owner_email": "nadia@example.com"}'{"name":"explain","template":"Explain simply: {question}","model":"bitbyte-mini","temperature":0.7,"tags":[],"id":1}
{"name":"explain","template":"Explain like I am new to AI: {question}","model":"bitbyte-mini","temperature":0.4,"tags":[],"id":1}
{"detail":"Prompt 5 not found"}8001 পোর্টের সার্ভার আলাদা প্রসেস, তাই তার PROMPTS নিজের মতো খালি ছিল: নতুন প্রম্পট সেখানে পেয়েছে আইডি ১। পূর্ণাঙ্গ উত্তরে নতুন নামটা অন্য কোনো প্রম্পটের হলে 409-ও ফেরত দেওয়া উচিত।
from pydantic import field_validator
class TaggedPrompt(BaseModel):
tags: Annotated[list[str], Field(max_length=5)] = []
@field_validator("tags")
@classmethod
def clean_tags(cls, tags: list[str]) -> list[str]:
cleaned = [t.strip().lower() for t in tags if t.strip()]
return list(dict.fromkeys(cleaned)) # ডুপ্লিকেট বাদ দিন, ক্রম ঠিক রাখুন
print(TaggedPrompt(tags=["Tutor", "tutor ", "short", " "]).tags)['tutor', 'short']
মডেলে পরিষ্কার করলে সেটা একবারই হয়, সব এন্ডপয়েন্ট ও সব ক্লায়েন্টের জন্য, আপনার কোড ডেটা দেখার আগেই; তাই ট্যাগ দিয়ে ফিল্টার ও ডুপ্লিকেট পরীক্ষা সহজভাবেই কাজ করে। ক্রমটা খেয়াল করুন: max_length=5 সীমা লিস্টে পরীক্ষা হয় এই ভ্যালিডেটর চলার আগে (এটা একটা "after" ভ্যালিডেটর), তাই ছয়টা এলোমেলো ট্যাগ পরিষ্কার করলে পাঁচে নামলেও তা বাতিল হয়।
সারসংক্ষেপ
- Pydantic মডেল হলো টাইপ-করা ফিল্ডওয়ালা ক্লাস; তৈরির সময় ডেটা যাচাই ও রূপান্তর হয়, আর
model_dump()/model_dump_json()তাকে আবার dict বা JSON করে। - নিয়ম রাখুন মডেলে: দৈর্ঘ্য, পরিসর ও প্যাটার্নে
Field, নির্দিষ্ট বিকল্পেLiteral,EmailStr-এর মতো বিশেষ টাইপ, আর নিজের পরীক্ষায়@field_validator। - 422 প্রতিটা সমস্যা
type,loc,msgওinputসহ জানায়; অবৈধ ডেটা নিয়ে আপনার এন্ডপয়েন্ট কখনো চলে না। - রিটার্ন টাইপ রেসপন্স ফিল্টার করে: ইনপুট ও পাবলিক আলাদা মডেল রাখুন, যাতে ভেতরের ফিল্ড ফাঁস না হয়।
- স্ট্যাটাস কোড ভেবেচিন্তে বাছুন: তৈরিতে
status_code=201, মোছায় বডি ছাড়া204, আর 404 ও 409-এHTTPException। স্কিমা ও এন্ডপয়েন্টappপ্যাকেজের আলাদা ফাইলে রাখুন।
এরপর: pytest দিয়ে আপনার API টেস্ট। প্রতিটা এন্ডপয়েন্ট curl দিয়ে হাতে পরীক্ষা করার বদলে এমন টেস্ট লিখবেন, যা কোড বদলানোর পর প্রতিবার সেকেন্ডে সবকিছু পরীক্ষা করে দেয়।