অধ্যায় 10 · প্যাকেজিং ও বিগিনার ক্যাপস্টোন
প্যাকেজিং, কনফিগারেশন ও পুনরুৎপাদনযোগ্যতা
- পৃষ্ঠা 19 / 22
- 22 মিনিট পড়া
পুনের প্রিয়া একটা ভালো অ্যাসিস্ট্যান্ট বানিয়ে সহকর্মীকে পাঠালেন। প্রথম কমান্ডেই সেটা ব্যর্থ: ভুল Python ভার্সন, একটা প্যাকেজ নেই, আর ডেটা ফোল্ডার খুঁজে পাচ্ছে না। ধারণায় কোনো ভুল নেই; প্রজেক্টটাই পুনরুৎপাদনযোগ্য (reproducible) নয়। একটা ফিচার তখনই আসল কাজের হয়, যখন অচেনা কেউ সেটা ইনস্টল করতে, অফলাইনে চালাতে, টেস্ট করতে আর সুস্থ কি না দেখতে পারে, আপনি যে ফল পেয়েছিলেন ঠিক সেই ফলসহ।
আগের মতোই এখানে সবকিছু লোকাল। মডেলটা এই পাতার জন্য লেখা ছোট একটা অফলাইন বিকল্প, ভাষা মডেল নয়; আসল মডেল কম অনুমানযোগ্য, আর ঠিক সে কারণেই টেস্ট করতে হয়।
যা শিখবেন
- প্রজেক্টকে ছোট মডিউলে সাজানো যাতে নির্ভরতা একদিকে যায়, আর কোন লাইব্রেরি ফাইল টেস্টের ভেতরে হাত বাড়ায় তা চিনে নেওয়া।
- নির্ভরতা পিন ও নথিভুক্ত করা, অফলাইন মোডকে ডিফল্ট রেখে সেটিংস ভ্যালিডেট করা, আর একটা কমান্ডে সব টেস্ট চালানো।
- ফিক্সচার ডেটা এমনভাবে প্রজেক্টের সঙ্গে পাঠানো যাতে ডেমো যেকোনো ফোল্ডার থেকে চলে, আর আলাদা হেলথ ও রেডিনেস চেকসহ ন্যূনতম এন্ডপয়েন্ট দেওয়া।
- আসল আউটপুট থেকে README লেখা, ট্রাস্ট বাউন্ডারিসহ ডেটা ফ্লো আঁকা, আর পরিষ্কার এনভায়রনমেন্টে প্রজেক্টটা পুনরুৎপাদন করা।
প্রজেক্টকে ছোট মডিউলে সাজান
প্রতিটা মডিউলের একটাই কাজ, আর ইমপোর্ট একদিকে যায়: সার্ভিস ইমপোর্ট করে সেফ-ফেইলিউর স্তর, সেটা ইমপোর্ট করে সিকিউরিটি, পারমিশন ও রিট্রিভাল, আর সেগুলো ইমপোর্ট করে সাধারণ ক্লায়েন্ট টাইপ। নিচের কিছু কখনো ওপরের কিছু ইমপোর্ট করে না। গত আঠারোটা পাতায় বানানো প্যাকেজটাকে টেবিলে ভাগ করা হলো।
| স্তর | মডিউল | দায়িত্ব |
|---|---|---|
| সার্ভিস | api | সেটিংস, /ask, /health, /ready |
| নিরাপত্তা | safefail, security, permissions, limits | একটাই রেসপন্স আকার, নিয়ন্ত্রণ, অ্যালাউলিস্ট, বাজেট |
| মান | evals, gate, usage, cache | ডেটাসেট, রিলিজ সিদ্ধান্ত, খরচ, ক্যাশিং |
| উত্তর তৈরি | rag, citations, results, contracts, streaming, tools | সাইটেশনসহ উত্তর ও আউটপুট চুক্তি |
| রিট্রিভাল | chunking, index, embeddings, bm25, context, context_quality | ডকুমেন্ট থেকে প্রমাণ |
| ভিত্তি | llm, config, errors | প্রোভাইডার-নিরপেক্ষ ক্লায়েন্ট, সেটিংস, এরর ক্লাস |
লাইব্রেরি কোড কখনো টেস্ট ইমপোর্ট করবে না, কারণ তাহলে লাইব্রেরি ইনস্টল করতে টেস্ট ফোল্ডার লাগে। একটা কমান্ডে যাচাই করুন।
grep -rn 'from tests' evidencedeskevidencedesk/gate.py:230: from tests.test_citations import REPLIES # the hand-written replies of an earlier pageএকটাই হিট, আর সেটা স্বীকার করাই সততা: gate.hand_written_client ইমপোর্ট করে tests/test_citations.py-এর হাতে লেখা রিপ্লাই (দেখুন রুব্রিক, রিগ্রেশন ও রিলিজ গেট)। প্রজেক্ট ফোল্ডার ইমপোর্ট পাথে থাকায় এটা কাজ করে, কিন্তু এটা একটা খুঁত। সমাধান হলো ওই রিপ্লাইগুলো data/-এর নিচে একটা ডেটা ফাইলে সরানো; পরের কোনো রিলিজে সেটা করা যাবে। ততদিন একটা টেস্ট এটাকে আটকে রাখে, যাতে দ্বিতীয় কোনো ইমপোর্ট নজর এড়িয়ে ঢুকতে না পারে।
নির্ভরতা পিন ও নথিভুক্ত করুন
তিনটা জিনিস লিখে রাখুন: যে Python ভার্সনে টেস্ট করেছেন, প্যাকেজগুলোর সঠিক ভার্সন, আর ইনস্টলের ধাপ। নিচের ফাইলে শুধু তা-ই আছে যা কিট ইমপোর্ট করে এবং যা টেস্টে লাগে। কোর্সের প্যাকেজ তালিকার jsonschema ও pyyaml এখানে নেই: কিটের কোনো ফাইল সেগুলো ইমপোর্ট করে না, আর যে নির্ভরতা কেউ ব্যবহার করে না তা কেবল ঝুঁকি।
# file: kit/requirements.txt
# Direct dependencies, exact versions, tested with Python 3.12.3. Install: python -m pip install -r requirements.txt
pydantic==2.14.1
fastapi==0.143.0
numpy==2.5.4
pytest==9.1.1
httpx2==2.13.1এই পাঁচ লাইন আরও প্যাকেজ টেনে আনে। কোনগুলো? একটা নতুন এনভায়রনমেন্ট বানিয়ে ইনস্টল করুন, তারপর pip-কে জিজ্ঞেস করুন। নিচের আউটপুট Linux-এ একটা পরিষ্কার Python 3.12.3 এনভায়রনমেন্ট থেকে এসেছে; পুরো তালিকা requirements.lock.txt নামে সংরক্ষিত।
python -m pip freezeannotated-doc==0.0.5
annotated-types==0.8.0
anyio==4.15.1
fastapi==0.143.0
h11==0.16.0
httpcore2==2.13.1
httpx2==2.13.1
idna==3.20
iniconfig==2.3.1
numpy==2.5.4
opentelemetry-api==1.45.1
packaging==26.3
pluggy==1.6.0
pydantic==2.14.1
pydantic_core==2.50.1
Pygments==2.21.0
pytest==9.1.1
starlette==1.7.0
truststore==0.10.5
typing-inspection==0.4.4
typing_extensions==4.16.0লক্ষ করুন httpx2 তালিকায় আছে, আর আপনার না-বাছা একটা প্যাকেজ (opentelemetry-api) অন্য নির্ভরতার মাধ্যমে এসেছে। পিন করা তালিকা দেখায় আপনি আসলে কী চালাচ্ছেন। ভার্সন দিয়ে পিন করা মানে কনটেন্ট দিয়ে পিন করা নয়: ফাইলে কোনো হ্যাশ নেই, তাই বদলে দেওয়া প্যাকেজ ধরা যাবে না। হ্যাশ যাচাই আছে, কিন্তু তা সব-অথবা-কিছুই-না: প্রতিটা প্যাকেজ পিন ও হ্যাশ করা লাগে; এমন ফাইল টুল বানিয়ে দেয়, হাতে লেখা হয় না।
টুল নোট (২০২৬-১০-১২ অনুযায়ী): Python 3.12 2028-10 পর্যন্ত সমর্থিত বলে তালিকাভুক্ত, আর 3.10-এর জীবনকাল শেষ হয়েছে 2026-10-01 তারিখে; বর্তমান numpy-র জন্য Python 3.12 বা নতুন লাগে, তাই প্রজেক্টে লেখা আছে
>=3.12। pytest-এর একটা মাঝারি মাত্রার অ্যাডভাইজরি 9.0.3-এ ঠিক হয়েছে, তাই প্রজেক্ট 9.0.3 বা নতুন চায়। pip-এর হ্যাশ-চেকিং মোডে (--require-hashes)==পিন আর প্রতিটা নির্ভরতার হ্যাশ লাগে;uv.lockশুধু uv টুলের জন্য, আরpylock.toml(PEP 751) একটা স্ট্যান্ডার্ড বিকল্প। ভার্সন বদলায়: বর্তমান ডকুমেন্টেশন দেখে নিন।
প্রজেক্ট ফাইল এগুলো এক জায়গায় বাঁধে। এতে আছে Python-এর শর্ত, নির্ভরতার রেঞ্জ, টেস্টের এক্সট্রা ও pytest সেটিংস (একটা টেস্ট কমান্ডের অংশে আবার কাজে লাগবে)।
# file: kit/pyproject.toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "evidencedesk"
version = "0.19.0"
description = "EvidenceDesk: an offline, tested help-centre assistant (AI Engineering with LLMs, Beginner)."
readme = "README.md"
requires-python = ">=3.12" # tested on 3.12.3; current numpy needs 3.12 or newer
dependencies = [
"pydantic>=2.14,<3",
"fastapi>=0.143,<1",
"numpy>=2.5,<3",
]
[project.optional-dependencies]
test = [
"pytest>=9.0.3", # earlier versions have a published advisory
"httpx2>=2.13,<3", # the client behind fastapi.testclient.TestClient
]
[tool.setuptools]
packages = ["evidencedesk"] # corpus/, data/ and fixtures/ sit beside the package: use a source checkout or an editable install
[tool.pytest.ini_options]
pythonpath = ["."] # `python -m pytest` and `pytest` both import evidencedesk and tests from the project folder
testpaths = ["tests"]
addopts = "-q"প্যাকেজ ফোল্ডারগুলো evidencedesk/-এর পাশে থাকলে পরিষ্কার এনভায়রনমেন্টে python -m pip install -e . --no-deps কাজ করেছে, অর্থাৎ নীতিগতভাবে প্রজেক্টটা ইনস্টলযোগ্য। একবার নেটওয়ার্ক লেগেছে, কারণ pip বিল্ড টুল নামায়। README-র ইনস্টল ধাপগুলো requirements.txt ব্যবহার করে; নিচে সেই পথেই পুনরুৎপাদন করবেন।
একটা নতুন মডিউল: সার্ভিস
kit/evidencedesk/api.py তৈরি করুন। এতে রিট্রিভাল বা নিরাপত্তার নতুন লজিক নেই; আগে যা আছে তা জুড়ে দেয়। অংশ ১ কনফিগারেশন, অংশ ২ ডেটা পাথ, অংশ ৩ অফলাইন মডেল, অংশ ৪ রিকোয়েস্ট ও রেসপন্স মডেল, অংশ ৫ অ্যাপ, অংশ ৬ ডেমো। নিচের অংশগুলো প্রতিটা অংশ চালায়।
# file: kit/evidencedesk/api.py
"""EvidenceDesk service layer: validated app settings, a small offline model, /ask, /health and /ready."""
from __future__ import annotations
import itertools
import json
import re
from collections.abc import Mapping
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
from evidencedesk.chunking import chunk_corpus
from evidencedesk.config import PREFIX, Settings, load_settings
from evidencedesk.llm import LLMRequest, LLMResponse, Usage
from evidencedesk.permissions import Outbox
from evidencedesk.rag import Retriever
from evidencedesk.safefail import KIT, SafeResponse, respond, safe_answer
from evidencedesk.security import OwnedStore, SecretGuard, SIMULATED_SECRET, SafeLog, Session, load_records, session_gate
REQUIRED_DATA = ("corpus", "fixtures/responses.json", "data/rag_cases.jsonl", "data/abuse/abuse_set.jsonl", "data/attacks/attacks.jsonl")
HTTP_OF = {"unauthenticated": 401, "forbidden_task": 403, "input_too_large": 413, "not_ready": 503} # by code; else by status
HTTP_BY_STATUS = {"answered": 200, "refused": 200, "blocked": 403, "failed": 500}
# --- 1. configuration: the p02 Settings plus two fields; offline is the default --------------------------
class AppSettings(Settings):
offline: bool = True # no network, no key: only the fake provider may run
corpus_dir: Path = KIT / "corpus"
@model_validator(mode="after")
def _check_offline(self) -> AppSettings:
if self.offline and self.provider != "fake":
raise ValueError(f"{PREFIX}OFFLINE is on, so provider {self.provider!r} is not allowed: set {PREFIX}OFFLINE=0 on purpose")
return self
def load_app_settings(env: Mapping[str, str]) -> AppSettings:
"""Same variables as load_settings, plus EVIDENCEDESK_OFFLINE (default true) and EVIDENCEDESK_CORPUS_DIR."""
data = load_settings(env).model_dump()
for field in ("offline", "corpus_dir"):
if env.get(PREFIX + field.upper()):
data[field] = env[PREFIX + field.upper()]
return AppSettings(**data)
# --- 2. data that travels with the project -----------------------------------------------------------------
def missing_data(root: Path = KIT) -> list[str]:
"""Required data paths that are absent. The paths hang off the project folder, never off the working directory."""
return [p for p in REQUIRED_DATA if not (root / p).exists()]
# --- 3. a tiny offline model: it answers with the first sentence of the best evidence ------------------------
class OfflineClient:
"""Not a language model. It lets the whole service run with no key and no network."""
EVIDENCE = re.compile(r'<evidence source="([^"]+)"[^>]*>\n(.*?)\n</evidence>', re.S)
def complete(self, request: LLMRequest) -> LLMResponse:
found = self.EVIDENCE.search(request.messages[-1].content)
text = "I could not read the evidence." if not found else f"{found[2].split('. ')[0].strip().rstrip('.')}. [{found[1]}]"
return LLMResponse(json.dumps({"answer": text, "tool_requests": []}), "offline-fake", Usage(0, 0))
# --- 4. request and response models --------------------------------------------------------------------------
class AskRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
question: str = Field(min_length=1) # the size limit belongs to safe_answer, so it answers 413 in one shape
class AskResponse(BaseModel):
status: Literal["answered", "refused", "blocked", "failed"]
message: str
request_id: str
class Ready(BaseModel):
ready: bool
reason: str = ""
chunks: int = 0
offline: bool = True
def load_retriever(settings: AppSettings) -> Retriever:
if not settings.corpus_dir.is_dir():
raise FileNotFoundError("corpus folder")
if not (chunks := chunk_corpus(settings.corpus_dir)):
raise ValueError("corpus has no documents")
return Retriever(chunks, clock=lambda: "2026-10-13T09:00:00Z")
# --- 5. the app: liveness answers always; readiness only after the corpus is loaded -------------------------
def create_app(settings: AppSettings | None = None, client: Any = None, loader: Any = load_retriever) -> FastAPI:
settings = settings or AppSettings()
guard = SecretGuard({"api_key": SIMULATED_SECRET})
state: dict[str, Any] = {"retriever": None, "reason": "not_loaded", "log": SafeLog(guard), "ids": itertools.count(1)}
store, outbox, model = OwnedStore(load_records()), Outbox(), client or OfflineClient()
@asynccontextmanager
async def lifespan(_app: FastAPI):
try:
state["retriever"], state["reason"] = loader(settings), ""
except Exception as exc: # the type is enough to start the investigation; the text may hold paths
state["reason"] = type(exc).__name__
yield
app = FastAPI(title="EvidenceDesk", lifespan=lifespan)
app.state.service = state
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "alive"} # the process is running; says nothing about the corpus
@app.get("/ready", response_model=Ready)
def ready() -> JSONResponse:
retriever = state["retriever"]
body = Ready(ready=retriever is not None, reason=state["reason"], chunks=len(retriever.chunks) if retriever else 0,
offline=settings.offline)
return JSONResponse(body.model_dump(), status_code=200 if body.ready else 503)
@app.post("/ask", response_model=AskResponse)
def ask(body: AskRequest, x_user_id: str = Header(default=""), x_request_id: str = Header(default="")) -> JSONResponse:
request_id = x_request_id[:40] or f"req-{next(state['ids'])}"
if state["retriever"] is None:
answer = respond("not_ready", request_id, log=state["log"], reason=state["reason"])
else:
session = Session(x_user_id.strip(), frozenset({"faq"})) # a stand-in for real authentication (Level 0)
gate = session_gate(session, settings.corpus_dir, store, outbox)
answer = safe_answer(model, state["retriever"], body.question, session, gate, guard, "faq", request_id, state["log"])
return JSONResponse(answer.public(), status_code=HTTP_OF.get(answer.code, HTTP_BY_STATUS[answer.status]))
return app
# --- 6. the offline demo behind `python -m evidencedesk.api` ----------------------------------------------------
def demo() -> str:
from fastapi.testclient import TestClient # no server, no network: the app is called in-process
lines = []
with TestClient(create_app()) as http:
for method, path, kw in (("GET", "/health", {}), ("GET", "/ready", {}),
("POST", "/ask", {"json": {"question": "How long do refunds take after inspection?"}, "headers": {"x-user-id": "mateo"}}),
("POST", "/ask", {"json": {"question": "Can I pay in instalments?"}, "headers": {"x-user-id": "mateo"}}),
("POST", "/ask", {"json": {"question": "Can I pay in instalments?"}})):
r = http.request(method, path, **kw)
body = r.json()
shown = json.dumps(body, sort_keys=True) if path != "/ask" else f"{body['status']} {body['request_id']}: {body['message']}"
lines.append(f"{method} {path} -> {r.status_code} {shown}")
return "\n".join(lines)
if __name__ == "__main__":
print(demo())কনফিগারেশন স্তর যোগ করুন
Level 0-এ এনভায়রনমেন্ট ভেরিয়েবল ও Pydantic শেখানো হয়েছে; এখানে আসল কথা ডিফল্ট। AppSettings অ্যাডাপ্টার, কনফিগারেশন ও এরর হ্যান্ডলিং-এর সেটিংস ক্লাসকে না বদলে বাড়ায়: offline (ডিফল্ট true) ও কর্পাস ফোল্ডার যোগ করে, আর অফলাইন চালু থাকলে আসল প্রোভাইডার নাকচ করে। ফলে একটা ভুলে-যাওয়া ভেরিয়েবল কখনো অফলাইন ডেমোকে হোস্টেড সার্ভিসের কলে বদলে দিতে পারে না।
from pydantic import ValidationError
from evidencedesk.api import load_app_settings
s = load_app_settings({})
print("defaults:", s.offline, s.provider, s.api_key, s.corpus_dir.name)
env = {"EVIDENCEDESK_PROVIDER": "openai", "EVIDENCEDESK_MODEL": "some-model", "EVIDENCEDESK_API_KEY": "sk-demo-1234"}
for label, e in (("provider set, offline on", env), ("offline switched off", {**env, "EVIDENCEDESK_OFFLINE": "0"}),
("timeout -1", {"EVIDENCEDESK_TIMEOUT_S": "-1"})):
try:
got = load_app_settings(e)
print(f"{label:24} -> ok: {got.provider}, key {got.api_key}")
except ValidationError as exc:
print(f"{label:24} -> {exc.errors()[0]['msg'].split(':')[0]}")defaults: True fake None corpus
provider set, offline on -> Value error, EVIDENCEDESK_OFFLINE is on, so provider 'openai' is not allowed
offline switched off -> ok: openai, key **********
timeout -1 -> Input should be greater than 0তিনটা জিনিস লক্ষ করুন। কিছু সেট না করলে ফেক প্রোভাইডার, কোনো API কি ছাড়া অফলাইন। প্রোভাইডার ও API কি দিলেও ব্যর্থ হয়, যতক্ষণ না কেউ ইচ্ছা করে অফলাইন বন্ধ করে। বন্ধ করলে কি তারকাচিহ্ন (*) হয়ে ছাপা হয়। আর ভুল মান প্রথম রিকোয়েস্টে নয়, চালুর সময়েই প্রোগ্রাম থামায়।
ফিক্সচার ডেটা প্রজেক্টের সঙ্গে প্যাকেজ করুন
কর্পাস, ডেটাসেট ও রেকর্ড করা রিপ্লাই কোডের সঙ্গে যেতে হবে, এবং আপনি যে ফোল্ডার থেকেই শুরু করুন সেখানে পাওয়া যেতে হবে। কিট প্রতিটা পাথ প্রজেক্ট ফোল্ডারে বেঁধে রাখে (Path(__file__)), ওয়ার্কিং ডিরেক্টরিতে নয়। missing_data ডেমোর জন্য দরকারি পাঁচটা পাথ যাচাই করে।
import os
import tempfile
from pathlib import Path
from evidencedesk.api import REQUIRED_DATA, missing_data
here = os.getcwd()
os.chdir(tempfile.mkdtemp()) # a folder that is not the project
print("from another folder, missing:", missing_data())
print("a folder without the data, missing:", len(missing_data(Path("elsewhere"))), "of", len(REQUIRED_DATA))
os.chdir(here)from another folder, missing: []
a folder without the data, missing: 5 of 5প্রজেক্ট নয় এমন ফোল্ডার থেকে শুরু করলেও কিছু অনুপস্থিত নয়। দ্বিতীয় লাইনে যাচাইটা নিজেই ব্যর্থ হয়, ডেটা না থাকলে যেমন হওয়া উচিত। একটা সৎ সীমাবদ্ধতা: pyproject.toml শুধু evidencedesk/ প্যাকেজ করে, তাই বিল্ড করা wheel-এ corpus/ থাকবে না। সোর্স চেকআউট বা এডিটেবল ইনস্টল ব্যবহার করুন, অথবা ডেটা প্যাকেজের ভেতরে এনে প্যাকেজ ডেটা হিসেবে ঘোষণা করুন; এই প্রজেক্ট ডেটা প্যাকেজের পাশে রাখে যাতে আগের পাতাগুলো অপরিবর্তিত থাকে।
ন্যূনতম FastAPI এন্ডপয়েন্ট দিন
HTTP, রুট ও রিকোয়েস্ট মডেল Level 0-এর জ্ঞান (দেখুন AI-এর জন্য Git, API ও ব্যাকএন্ড: বিগিনার), তাই এখানে শুধু নকশার সিদ্ধান্ত গুরুত্বপূর্ণ। POST /ask নেয় {"question": "..."} এবং প্রতিবার একই তিন ফিল্ড ফেরত দেয়: status, message ও request_id। পরিচয়, আকার, রিট্রিভাল ও ব্যর্থতা সামলাতে এটা নিরাপদ ব্যর্থতা ও নিরাপত্তা রিগ্রেশন টেস্ট-এর safe_answer ব্যবহার করে, আর স্ট্যাটাসকে HTTP কোডে বদলায়: পরিচয় ও অনুমতির জন্য 401 ও 403, বড় প্রশ্নের জন্য 413, সার্ভিস প্রস্তুত না হলে 503, আমাদের নিজের ব্যর্থতায় 500। প্রমাণ নেই বলে প্রত্যাখ্যান একটা স্বাভাবিক উত্তর, কোড 200। হেডার x-user-id আসল অথেনটিকেশনের বদলি; সেটা এই পাতায় শেখানো হয় না। টেস্ট অ্যাপটাকে TestClient দিয়ে প্রসেসের ভেতরেই ডাকে: কোনো সার্ভার নেই, নেটওয়ার্ক নেই।
টুল নোট (২০২৬-১০-১২ অনুযায়ী): Starlette-এর ডকুমেন্টেশন বলে
TestClienthttpx2-এর ওপর তৈরি এবং সাধারণhttpxএখনো সমর্থিত কিন্তু অপ্রচলিত (deprecated); আর FastAPI-র টেস্টিং পাতা এখনোhttpxইনস্টল করতে বলে। ওই তারিখে দুই পাতা একমত ছিল না। এই প্রজেক্টhttpx2পিন করে; সতর্কবার্তাকে এরর ধরে চালালেও কোনো সতর্কবার্তা ছাপা হয়নি। বর্তমান ডকুমেন্টেশন দেখে নিন।
হেলথ ও রেডিনেস চেক যোগ করুন
চলমান প্রসেস মানেই কাজ করা ফিচার নয়। লাইভনেস (/health) জিজ্ঞেস করে "প্রসেসটা চালু আছে?" এবং সবসময় 200 দেয়। রেডিনেস (/ready) জিজ্ঞেস করে "এটা প্রশ্নের উত্তর দিতে পারবে?" এবং চালুর ধাপে কর্পাস লোড ও ইনডেক্স হওয়ার পরেই কেবল সত্য। প্ল্যাটফর্ম লাইভ নয় এমন প্রসেস রিস্টার্ট করে; আর প্রস্তুত নয় এমনটাতে শুধু ট্রাফিক পাঠানো বন্ধ করে। দুটো গুলিয়ে ফেললে সুস্থ প্রসেস বারবার রিস্টার্ট হয়। প্রোবটা তিন অবস্থা চালায়।
from contextlib import nullcontext
from fastapi.testclient import TestClient
from evidencedesk.api import AppSettings, create_app
def probe(label, app, started=True):
client = TestClient(app)
with (client if started else nullcontext(client)) as http: # `with` runs the start-up step that loads the corpus
health, ready = http.get("/health"), http.get("/ready")
ask = http.post("/ask", json={"question": "How long do refunds take?"}, headers={"x-user-id": "mateo"})
print(f"{label:16} health {health.status_code} | ready {ready.status_code} {ready.json()['reason'] or 'ok':14} | ask {ask.status_code} {ask.json()['status']}")
probe("loaded", create_app())
probe("corpus missing", create_app(AppSettings(corpus_dir="no-such-folder")))
probe("never started", create_app(), started=False)loaded health 200 | ready 200 ok | ask 200 answered
corpus missing health 200 | ready 503 FileNotFoundError | ask 503 failed
never started health 200 | ready 503 not_loaded | ask 503 failedতৃতীয় সারি লক্ষ করুন: অ্যাপ আছে কিন্তু চালুর ধাপ কখনো চলেনি, তাই সেটা লাইভ কিন্তু প্রস্তুত নয়, আর /ask সাধারণ ব্যর্থতার বার্তাসহ 503 দেয়। দ্বিতীয় সারিতে কারণ হিসেবে এক্সেপশনের টাইপ আছে; এক্সেপশনের লেখা, যাতে পাথ থাকতে পারে, ফেরত যায় না।
টেস্টের জন্য একটাই কমান্ড বানান
pyproject.toml-এর [tool.pytest.ini_options] টেবিল প্রজেক্ট ফোল্ডারকে ইমপোর্ট পাথে যোগ করে এবং tests/ দেখিয়ে দেয়। প্রজেক্ট ফোল্ডার থেকে একটা কমান্ডেই সব চলে, প্রতিটা মেশিনে একই রকম:
python -m pytest | tail -1 | sed 's/ in [0-9.]*s//'455 passed, 1 xfailedব্লকটা শুধু শেষ লাইন দেখায়, আর ঘড়ির সেকেন্ড sed দিয়ে বাদ দেওয়া হয়েছে যাতে প্রতিবার আউটপুট একই থাকে। একটা xfailed হলো টয় এমবেডারের নথিভুক্ত সীমা। নতুন ফাইল tests/test_api.py ২৫টা টেস্ট যোগ করে: সেটিংস, ডেটা পাথ, দুই প্রোব, রেসপন্সের আকার, ভুল বডি, ক্র্যাশ করা মডেল, প্যাকেজিং ফাইলের আকার, প্রতিটা থার্ড-পার্টি ইমপোর্ট ঘোষিত কি না তার যাচাই, আর ওই খুঁতের পিন করা টেস্ট।
পুনরুৎপাদনযোগ্য README লিখুন
README মানুষের জন্য লেখা একটা টেস্ট। এর প্রতিটা কমান্ড চলতে হবে, আর প্রতিটা আউটপুট আসল হতে হবে। নিচের ফাইলটা একটা ছোট স্ক্রিপ্ট বানিয়েছে, যা ডেমো ও স্যুট চালিয়ে ফল লেখে; একটা টেস্ট (test_readme_shows_the_real_demo_output) ডেমোর আউটপুট ও README না মিললে ব্যর্থ হয়, তাই লেখা চুপচাপ সরে যেতে পারে না।
# file: kit/README.md
# EvidenceDesk
A small help-centre assistant for the fictional company Kestrel Cycles. It retrieves evidence from a local corpus, answers with
citations, refuses when evidence is missing, and fails safe. **It runs fully offline with a fake model: no API key, no network.**
The fake model is not a language model; real models are less predictable, which is why the project is tested.
## Requirements
- Python 3.12 (tested on 3.12.3). Linux, macOS or Windows.
- The packages in `requirements.txt` (exact versions). `requirements.lock.txt` lists every installed package of the tested environment.
## Install
```bash
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install -r requirements.txt
```
## Run the offline demo
The demo calls the API in-process (no server, no network): liveness, readiness, one answer, one missing-evidence refusal, one
request with no signed-in user.
```bash
python -m evidencedesk.api
```
```text
GET /health -> 200 {"status": "alive"}
GET /ready -> 200 {"chunks": 36, "offline": true, "ready": true, "reason": ""}
POST /ask -> 200 answered req-1: Refunds go back to the original payment method within 5 business days after the warehouse receives the item. [kc-returns-v3:c2]
POST /ask -> 200 refused req-2: Not enough evidence in the help centre to answer this. Please contact Kestrel Cycles support.
POST /ask -> 401 refused req-3: I cannot help with that request. Please contact Kestrel Cycles support.
```
## Test
```bash
python -m pytest
```
Expected last line (the time differs): `455 passed, 1 xfailed`. The one expected failure (`xfailed`) is a documented limit of the
toy embedder.
## Configuration
Environment variables, all optional. An empty value counts as not set.
| Variable | Default | Meaning |
|---|---|---|
| `EVIDENCEDESK_OFFLINE` | `true` | When true, only the fake provider may run. Set `0` on purpose to allow another provider. |
| `EVIDENCEDESK_PROVIDER` | `fake` | `fake`, `openai`, `anthropic`, `gemini` or `local`. Only `fake` is tested here. |
| `EVIDENCEDESK_MODEL` | `fake-model` | Required for a non-fake provider. |
| `EVIDENCEDESK_API_KEY` or `_API_KEY_FILE` | none | Needed by hosted providers; never printed. |
| `EVIDENCEDESK_TIMEOUT_S`, `EVIDENCEDESK_MAX_RETRIES` | `30`, `2` | Validated at start-up. |
| `EVIDENCEDESK_CORPUS_DIR` | the project's `corpus/` | Folder of documents to index. |
## API
- `GET /health`: the process is alive. Always 200.
- `GET /ready`: 200 only when the corpus is loaded and indexed; otherwise 503 with a short reason.
- `POST /ask` with `{"question": "..."}` and header `x-user-id` (a stand-in for real authentication). Answers in one shape:
`status` (`answered`, `refused`, `blocked`, `failed`), `message`, `request_id`. Codes and exception text are never returned.
## Layout
```text
evidencedesk/ llm, config, errors provider-neutral client, settings, error classes
context, context_quality context assembly, trust labels, budgets
embeddings, index, chunking, bm25, rag, citations retrieval and cited answers
tools, permissions one read-only tool, allowlists, approvals
results, contracts, streaming output contracts and streaming
evals, gate evaluation datasets and the release gate
usage, cache, limits cost, caching, budgets
security, safefail injection controls, safe responses, abuse set
api settings, /ask, /health, /ready
corpus/ data/ fixtures/ synthetic documents, datasets, recorded replies
tests/ pytest suite
```
Dependencies point downwards: `api` imports `safefail`, which imports `security`, and so on down to `llm`. Library code does not
import tests, with one known exception (see Limitations).
## Data flow and trust boundaries
```text
client (untrusted) --POST /ask--> api.py: validate body, read x-user-id
| safefail: identity, task, size
==== boundary 1: input from here on is hostile ================================
corpus files --> chunking --> index --> Retriever (text is data, not orders)
| evidence tagged trust="untrusted", secrets screened
==== boundary 2: the model is not trusted either ===============================
model (fake by default) --> reply must be valid JSON --> tool gate (allowlist,
| limits, approval) --> secret screen
client <-- {status, message, request_id} -- no codes, no internals, redacted log
```
## Limitations
- The fake model and the toy embedder are stand-ins. Retrieval quality on real documents is not measured.
- Four evaluation cases are known misses (rc03, rc06, rc08, rc13); the release gate compares against a stored baseline.
- The corpus is synthetic: English plus two Bangla pages. Other languages are untested.
- `x-user-id` is not authentication. Add real authentication before any real use.
- Only `fake` was run. Hosted providers are untested adapters; check the provider's current documentation.
- Data folders sit beside the package: use a source checkout or `pip install -e .`. A built wheel contains only `evidencedesk/`.
- `evidencedesk.gate.hand_written_client` imports `tests.test_citations`: a known wart, pinned by a test.
- No real server, concurrency or load was tested; `TestClient` calls the app in-process.
## Hashes
`requirements.lock.txt` pins versions but has no hashes. For installs that must be verified, produce a hashed file with a
dedicated tool and install with `python -m pip install --require-hashes -r <file>`; check pip's current documentation.ছোট আর্কিটেকচার ডায়াগ্রাম আঁকুন
README-র ডায়াগ্রাম দুটো প্রশ্নের উত্তর দেয়: ডেটা কোথায় যায়, আর আস্থা কোথায় বদলায়? ওপর থেকে নিচে পড়ুন।
client (untrusted) --POST /ask--> api.py: validate body, read x-user-id
| safefail: identity, task, size
==== boundary 1: input from here on is hostile ================================
corpus files --> chunking --> index --> Retriever (text is data, not orders)
| evidence tagged trust="untrusted", secrets screened
==== boundary 2: the model is not trusted either ===============================
model (fake by default) --> reply must be valid JSON --> tool gate (allowlist,
| limits, approval) --> secret screen
client <-- {status, message, request_id} -- no codes, no internals, redacted logদুটো লাইন গুরুত্বপূর্ণ। প্রথম সীমানার নিচে যা কিছু আসে (প্রশ্ন, ডকুমেন্টের লেখা) তা শত্রুভাবাপন্ন ধরা হয়, যেমন প্রম্পট ইনজেকশন ও অ্যাক্সেসের সীমানা পাতায় ছিল। দ্বিতীয় সীমানার নিচে মডেলের রিপ্লাই চালানোর হুকুম নয়, যাচাই করার অনুরোধ। ক্লায়েন্ট শুধু তিন ফিল্ড দেখে, কখনো কোড বা এক্সেপশন দেখে না।
অনুশীলনী: পরিষ্কার এনভায়রনমেন্টে প্রজেক্ট পুনরুৎপাদন করুন
README অক্ষরে অক্ষরে অনুসরণ করুন: নতুন ভার্চুয়াল এনভায়রনমেন্টে, কোনো API ক্রেডেনশিয়াল ছাড়া। kit ফোল্ডার আপনার পছন্দের জায়গায় কপি করে নাম দিন project, তারপর তার প্যারেন্ট ফোল্ডার থেকে নিচের ব্লক চালান। আমরা এটা খালি এনভায়রনমেন্টে (env -i) চালিয়েছি যাতে বাইরের কোনো ভেরিয়েবল সাহায্য করতে না পারে। প্রথম কমান্ড এক্সপোর্ট করা সেই ভেরিয়েবল গোনে যার নামে api_key আছে; এটা ০ ছাপতে হবে।
cd project
python3.12 -m venv .venv
source .venv/bin/activate
env | grep -ci api_key
python -m pip install -q -r requirements.txt
python -m pip freeze | diff - <(grep -v '^#' requirements.lock.txt) && echo "same as the lock file"
python -m pytest | sed 's/ in [0-9.]*s//'0
same as the lock file
........................ [100%]
455 passed, 1 xfailedকী ঘটল: ইনস্টল প্যাকেজ ইনডেক্স থেকে পাঁচটা পিন করা প্যাকেজ ও তাদের নির্ভরতা নামিয়েছে (সময়-নিরপেক্ষ তথ্য: একবার নেটওয়ার্ক লাগে, তারপর টেস্টে আর লাগে না); pip freeze লক ফাইলের সঙ্গে হুবহু মিলেছে, অর্থাৎ এনভায়রনমেন্টটা টেস্ট করা সেটাই; আর স্যুট কোনো ক্রেডেনশিয়াল ছাড়া পাস করেছে। ফল না মিললে প্রথম সন্দেহ Python ভার্সন (python -V বলতে হবে 3.12) আর চালু থাকা একটা পুরনো এনভায়রনমেন্ট। শেষে python -m evidencedesk.api চালিয়ে README-র সঙ্গে মেলান: বাইটে বাইটে এক হওয়া উচিত।
কাজের জায়গায় এটা কোথায় দেখা যায়
আক্রার কোয়ামে একটা টিমে যোগ দিয়ে README ছাড়া একটা AI ফিচার পেলেন। কোন Python ও কোন ডেটা ফোল্ডার লাগে তা খুঁজতে তাঁর দুদিন গেল। ভালো টিমে প্রথম দিনের কাজই ওপরের অনুশীলনী, আর প্রজেক্ট শেষ বলার আগে যিনি লিখেছেন তিনি এটা পরিষ্কার ল্যাপটপে চালান। প্ল্যাটফর্মও আপনার সার্ভিসকে একই প্রশ্ন করে: এটা লাইভ আছে, আর প্রস্তুত? রিলিজ চেকলিস্ট সাধারণত শুরু হয় "শূন্য থেকে ইনস্টল হয়" আর "অফলাইনে টেস্ট পাস করে" দিয়ে।
সাধারণ ভুল
- শুধু নিজের এনভায়রনমেন্টে টেস্ট করা। পুরনো প্যাকেজ না-ঘোষিত নির্ভরতা লুকিয়ে রাখে। নতুন এনভায়রনমেন্ট বানিয়ে ফাইল থেকে ইনস্টল করুন।
- পিন না করা বা অব্যবহৃত নির্ভরতা। "সর্বশেষ" আপনার অজান্তে বদলায়; অব্যবহৃত প্যাকেজ ঝুঁকি বাড়ায়। সঠিক ভার্সন পিন করুন আর তালিকা ইমপোর্ট করা প্যাকেজে সীমিত রাখুন।
- সবকিছুর জন্য একটাই হেলথ চেক। কর্পাস লাগে এমন লাইভনেস চেক সুস্থ প্রসেস রিস্টার্ট করায়। লাইভনেস সহজ রাখুন, লোডের অবস্থা রেডিনেসে দিন।
- ওয়ার্কিং ডিরেক্টরির সাপেক্ষে পাথ। আপনার মেশিনে চলে, প্ল্যাটফর্মের ফোল্ডারে ভাঙে। পাথ প্রজেক্ট ফাইলে বাঁধুন এবং অন্য ফোল্ডার থেকে টেস্ট করুন।
- যে কমান্ড কেউ চালায়নি তা README-তে রাখা। আসল রান থেকে আউটপুট কপি করুন আর তুলনা করার একটা টেস্ট যোগ করুন।
নিজে চেষ্টা করুন
- সহজ। অ্যাপটাকে একটা খালি ফোল্ডারে দেখান।
/health,/readyও কারণের জন্য তিনটা ফল আগে অনুমান করুন, তারপর চালান। - মাঝারি। নিরাপদ ব্যর্থতা ও নিরাপত্তা রিগ্রেশন টেস্ট আবার দেখুন।
/ask-এ ২০০১ অক্ষরের একটা প্রশ্ন আর একটা খালি প্রশ্ন পাঠান। কোন সীমা কোনটার উত্তর দেয়, আর দুটো আলাদা কেন? - কঠিন। মূল্যায়ন ডেটাসেট ও নির্ধারিত টেস্ট আবার দেখুন। প্রজেক্টের একটা কপিতে
data/eval_extra.jsonl-এর শেষে একটা স্পেস যোগ করে স্যুট চালান। কী ব্যর্থ হয়, আর সঠিক সমাধান কী?
উত্তর
সহজ। প্রসেস লাইভ, কিন্তু পরিবেশনের কিছু নেই, তাই সেটা প্রস্তুত নয়, আর কারণ চালুর ব্যর্থতার টাইপ।
import tempfile
empty = tempfile.mkdtemp() # a folder with no documents in it
with TestClient(create_app(AppSettings(corpus_dir=empty))) as http:
print(http.get("/health").status_code, http.get("/ready").status_code, http.get("/ready").json()["reason"])200 503 ValueErrorমাঝারি। আকারের সীমা নিরাপত্তা স্তরের (safe_answer), তাই লম্বা প্রশ্ন 413 সহ স্ট্যান্ডার্ড আকার পায়। খালি প্রশ্ন রিকোয়েস্ট মডেল ভাঙে, তাই আমাদের কোড চলার আগেই ফ্রেমওয়ার্ক 422 দিয়ে নাকচ করে। দুই স্তর, দুই আকার: ফ্রেমওয়ার্কের 422 detail ডেভেলপারদের জন্য।
with TestClient(create_app()) as http:
long = http.post("/ask", json={"question": "x" * 2001}, headers={"x-user-id": "mateo"})
blank = http.post("/ask", json={"question": ""}, headers={"x-user-id": "mateo"})
print(long.status_code, long.json()["status"], "|", blank.status_code, blank.json()["detail"][0]["loc"], blank.json()["detail"][0]["msg"])413 refused | 422 ['body', 'question'] String should have at least 1 characterকঠিন। ডেটা ফাইল রিলিজেরই অংশ। এর হ্যাশ আর ম্যানিফেস্টের সঙ্গে মেলে না, তাই ডেটাসেট লোড করলে DatasetDrift ওঠে আর টেস্ট কালেকশন থেমে যায়। সঠিক সমাধান বার্তাটাই বলছে: ইচ্ছা করে ডেটাসেটের ভার্সন বাড়ান ও ম্যানিফেস্ট হালনাগাদ করুন; টেস্ট বদলাবেন না।
rm -rf ../copy && cp -r . ../copy && echo ' ' >> ../copy/data/eval_extra.jsonl && cd ../copy && python -m pytest 2>&1 | tail -3 | cut -c1-118 | sed 's/ in [0-9.]*s//'ERROR tests/test_gate.py - evidencedesk.evals.DatasetDrift: eval_extra.jsonl ...
!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!!
2 errorsপাস চেকলিস্ট:
- সহজ:
200 503 ValueError, আর আপনি বলতে পারেন/healthকেন 200 থাকে। - মাঝারি: নিরাপত্তা স্তর থেকে
413আর মডেল থেকে422; কোন স্তর কোনটার মালিক তা আপনি বলেছেন। - কঠিন:
DatasetDrift-এর নাম থাকা কালেকশন-এরর, আর ভার্সন ও ম্যানিফেস্ট বদলানোর সমাধান, টেস্ট বদলানো নয়। - অনুশীলনী: API কি-র সংখ্যা ০, "same as the lock file" ছাপা হয়েছে, আর নতুন এনভায়রনমেন্ট থেকে স্যুট পাস করেছে।
সারসংক্ষেপ
- ছোট মডিউল, নির্ভরতা একদিকে; লাইব্রেরি কোড কখনো টেস্ট ইমপোর্ট করে না (একটা জানা ব্যতিক্রম, টেস্টে পিন করা)।
- টেস্ট করা Python ও সঠিক ভার্সন পিন করুন, তালিকা ব্যবহৃত প্যাকেজে সীমিত রাখুন, আর মনে রাখুন ভার্সন পিন মানে হ্যাশ নয়।
- চালুর সময় সেটিংস ভ্যালিডেট করুন; অফলাইন মোড ডিফল্ট, আর আসল প্রোভাইডারের জন্য ইচ্ছাকৃত সুইচ লাগে।
- ডেটা পাথ প্রজেক্টে বাঁধুন, একটাই রেসপন্স আকার দিন, আর লাইভনেস ও রেডিনেস আলাদা রাখুন।
- আসল আউটপুট থেকে README, একটা টেস্ট কমান্ড ও পরিষ্কার এনভায়রনমেন্টে রান প্রজেক্টকে পুনরুৎপাদনযোগ্য করে।
এরপর: ডকুমেন্টেশন, রিলিজ ও পোর্টফোলিওর মান এই কাজ করা, পুনরুৎপাদনযোগ্য প্রজেক্টকে ডকুমেন্টেশন, রিলিজ চেকলিস্ট আর এমন কেস স্টাডিতে রূপ দেয় যাতে একজন রিভিউয়ার আস্থা রাখতে পারেন।