অধ্যায় 3 · স্ট্রিমিং, কাঠামোবদ্ধ আউটপুট ও নির্ভরযোগ্য রেসপন্স
নির্ভরযোগ্য আউটপুট চুক্তি
- পৃষ্ঠা 6 / 22
- 21 মিনিট পড়া
আক্রার কোয়ামে আগের পাতার উত্তরের বক্সটা চালু করেছেন। তারপর দল উত্তরে একটা নতুন ফিল্ড যোগ করল আর পুরোনো একটা মোবাইল অ্যাপ ক্র্যাশ করল; একটা স্ট্রিম মাঝপথে থেমে গেল আর স্ক্রিনে অর্ধেক বাক্য চূড়ান্ত উত্তরের মতো দেখাল; আর একটা মডেল এমন একটা লিংক ফেরত দিল যা আসলে অন্য কোথাও নিয়ে যায়। এগুলো চুক্তির (contract) সমস্যা: ফ্রন্ট এন্ড কী ভরসা করতে পারে তা কেউ লিখে রাখেনি। এই পাতা স্ট্রিমিং ও কাঠামোবদ্ধ ফলাফল থেকে AnswerResult, Outcome, assemble আর FakeStreamClient আবার ব্যবহার করে (নতুন করে শেখায় না) এবং সেগুলোকে একটা চুক্তিতে পরিণত করে।
যা শিখবেন
- স্কিমার সংস্করণ, এবং খালি, আংশিক ও এরর অবস্থার জন্য একটাই অনুমানযোগ্য ফলাফল।
- অর্থপূর্ণ স্ট্রিম ইভেন্ট, বাতিল করা (cancellation), ইউনিকোড চাঙ্কের সীমানা ও আকারের সীমা।
- ফেরত আসা আইডি ও URL-কে অবিশ্বস্ত ডেটা হিসেবে দেখা, প্রোভাইডারের আউটপুট চুক্তি, রিগ্রেশন ফিক্সচার, আর পরীক্ষা করা একটা স্ট্রিমিং FAQ এন্ডপয়েন্ট।
স্কিমার সংস্করণ ব্যবহার করুন
স্কিমার সংস্করণ (schema version) হলো schema_version নামের একটা ফিল্ড, যা বলে এরপর কোন গড়নের ডেটা আসছে; তাই পরিবর্তন একটা সিদ্ধান্ত হয়, অপ্রত্যাশিত ঘটনা নয়। সংস্করণ ২ যোগ করে links। সংস্করণ-সচেতন রিডার read_answer ১ ও ২ গ্রহণ করে, দুটোকেই একটা অভ্যন্তরীণ মডেলে (Answer) রূপান্তর করে, আর অন্য যেকোনো মান, না থাকাও, আন্দাজ না করে বাতিল করে। সংস্করণ ১ তার পুরোনো নিয়মই রাখে, তাই links সহ v1 উত্তর অবৈধ। (type(version) is int আছে কারণ Python-এ True == 1।) মডিউলটা ফাইলের ক্রমে অংশে অংশে দেখানো হয়েছে; প্রথমটার পরের ব্লকগুলো একই ফাইলের ধারাবাহিক অংশ (কিটে পুরোটা আছে)। প্রবেশদ্বার parse_contract: আগে স্টপ রিজন, তারপর আকার, তারপর JSON সিনট্যাক্স, তারপর সংস্করণ ও স্কিমা।
# file: evidencedesk/contracts.py
"""Output contracts: versions, front-end states, event rules, bounds, untrusted values, adapters."""
import json
import unicodedata
from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import Annotated, Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
from evidencedesk.llm import LLMRequest, LLMResponse, Message, Usage
from evidencedesk.results import SORRY, AnswerResult, Outcome
from evidencedesk.streaming import Assembled, StreamEvent, assemble, safe_events
MAX_BYTES = 4000 # one structured reply
MAX_CHARS = 4000 # one streamed answer
HOSTS = frozenset({"help.kestrelcycles.example"})
NO_ANSWER = "No answer was produced. Please rephrase the question."
FAILURES = Path(__file__).resolve().parent.parent / "fixtures" / "failures.jsonl"
RETRY_LATER = {"invalid_json", "invalid_schema", "interrupted", "stream_error", "untrusted_value"}
GOOD = {"schema_version": 2, "answer": "30 days.", "cited_ids": ["kc-returns-v3"], "confidence": "high", "status": "answered"}
Ident = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9#._-]{0,63}$")]
class Answer(AnswerResult):
cited_ids: list[Ident] = Field(max_length=5)
links: list[Annotated[str, StringConstraints(max_length=200)]] = Field(default_factory=list, max_length=5)
def read_answer(payload: object) -> Outcome:
"""Accepts v1 and v2, rejects everything else instead of guessing."""
version = payload.get("schema_version") if isinstance(payload, dict) else None
if type(version) is not int or version not in (1, 2):
return Outcome(False, "unknown_version") # also for a reply that is not an object
body = {k: v for k, v in payload.items() if k != "schema_version"}
try:
if version == 1:
body = AnswerResult.model_validate(body).model_dump() # the old rules, unchanged
return Outcome(True, "ok", Answer.model_validate(body))
except ValidationError as exc:
found = tuple({"loc": ".".join(map(str, e["loc"])), "msg": e["msg"]} for e in exc.errors())
return Outcome(False, "invalid_schema", problems=found)
def parse_contract(response: LLMResponse, max_bytes: int = MAX_BYTES) -> Outcome:
if response.stop_reason != "end":
return Outcome(False, "truncated" if response.stop_reason == "length" else "unusable_stop")
if len(response.text.encode("utf-8")) > max_bytes:
return Outcome(False, "too_large") # reject, do not cut
try:
payload = json.loads(response.text)
except (ValueError, RecursionError):
return Outcome(False, "invalid_json")
return read_answer(payload)from evidencedesk.contracts import read_answer
v1 = {"schema_version": 1, "answer": "You can return a new bike within 30 days.", "cited_ids": ["kc-returns-v3"],
"confidence": "high", "status": "answered"}
v2 = {**v1, "schema_version": 2, "links": ["https://help.kestrelcycles.example/returns"]}
cases = {"v1": v1, "v2": v2, "v3 (future)": {**v2, "schema_version": 3},
"v1 with links": {**v1, "links": []}}
for name, payload in cases.items():
outcome = read_answer(payload)
print(f"{name:20} {outcome.code:16} links={outcome.value.links if outcome.ok else '-'}")v1 ok links=[]
v2 ok links=['https://help.kestrelcycles.example/returns']
v3 (future) unknown_version links=-
v1 with links invalid_schema links=-নতুন বাধ্যতামূলক ফিল্ড, নাম বদল বা মানের অর্থ বদলালে নতুন সংস্করণ লাগে; পুরোনো ক্লায়েন্ট বা ডেটা থাকা পর্যন্ত পুরোনো রিডার রাখুন।
খালি, আংশিক ও এরর অবস্থা নির্ধারণ করুন
নিয়ম না থাকলে প্রতিটা স্ক্রিন নিজের মতো করে ঠিক করে নেয়। Envelope হলো সেই একটাই গড়ন যা ফ্রন্ট এন্ড সবসময় পায়, যার state হয় ok, empty, partial অথবা error; to_envelope সেটা বানায় একটা Outcome (কাঠামোবদ্ধ উত্তর) বা একটা Assembled স্ট্রিম (সাধারণ টেক্সট) থেকে।
class Envelope(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal[2] = 2
state: Literal["ok", "empty", "partial", "error"]
text: str
code: str = "ok"
retryable: bool = False
result: Answer | None = None
def to_envelope(source: Outcome | Assembled) -> Envelope:
if isinstance(source, Outcome):
if source.ok:
return Envelope(state="ok", text=source.value.answer, result=Answer(**source.value.model_dump()))
return Envelope(state="error", text=SORRY, code=source.code, retryable=source.code in RETRY_LATER)
if source.status == "empty":
return Envelope(state="empty", text=NO_ANSWER, code="empty")
if source.status == "interrupted":
return Envelope(state="partial", text=source.text, code="interrupted", retryable=True)
if source.status == "error":
return Envelope(state="error", text=SORRY, code="stream_error", retryable=True)
if source.stop_reason == "length":
return Envelope(state="partial", text=source.text, code="truncated")
return Envelope(state="ok", text=source.text)from evidencedesk.contracts import to_envelope
from evidencedesk.results import Outcome
from evidencedesk.streaming import Assembled
sources = {"valid structured": read_answer(v2), "empty stream": Assembled("", "empty"),
"interrupted": Assembled("Standard delivery ta", "interrupted"),
"cut off (length)": Assembled("For storage longer than 30", "complete", stop_reason="length"),
"bad JSON": Outcome(False, "invalid_json")}
for name, source in sources.items():
e = to_envelope(source)
print(f"{name:17} {e.state:8} {e.code:12} retry={e.retryable!s:5} {e.text[:28]!r}")valid structured ok ok retry=False 'You can return a new bike wi'
empty stream empty empty retry=False 'No answer was produced. Plea'
interrupted partial interrupted retry=True 'Standard delivery ta'
cut off (length) partial truncated retry=False 'For storage longer than 30'
bad JSON error invalid_json retry=True 'Sorry, I could not produce a'ফ্রন্ট এন্ডের নিয়ম: সবসময় text দেখান; স্টাইলের জন্য state (যেমন partial-এ "কাটা পড়েছে" লেবেল), রিট্রাই বোতামের জন্য retryable, আর result শুধু তখনই যখন সেটা আছে। এররে একটা স্থির বার্তা দেখানো হয়; code লগের জন্য। আংশিক অবস্থা শুধু সাধারণ টেক্সটের জন্য, কারণ অর্ধেক JSON টেক্সট কোনো উত্তর নয়। খালি মানে এরর নয়: কল কাজ করেছে, কিন্তু কিছু বলেনি। কেটে যাওয়া স্ট্রিম পরের বার কাজ করতে পারে; দৈর্ঘ্যের সীমায় কাটা উত্তর আবারও কাটা পড়বে।
স্ট্রিমিং ইভেন্ট অর্থপূর্ণ রাখুন
একটা ইভেন্ট হয় কনটেন্ট চাঙ্ক, সমাপ্তি (done) অথবা এরর; শেষ দুটো টার্মিনাল (শেষ ইভেন্ট)। চুক্তি: পরিচিত ধরন, খালি চাঙ্ক নয়, ঠিক একটা টার্মিনাল ইভেন্ট, তারপর আর কিছু নয়। check_events ভাঙা নিয়মগুলো ফেরত দেয়; সার্ভার যা পাঠায় আর ক্লায়েন্ট যা পায়, দুই জায়গাতেই এটা চালান।
def check_events(events: Iterable[StreamEvent]) -> list[str]:
problems, ended = [], False
for n, event in enumerate(events):
if event.kind not in ("chunk", "done", "error"):
problems.append(f"event {n}: unknown kind {event.kind!r}")
elif ended:
problems.append(f"event {n}: {event.kind} after the stream ended")
elif event.kind == "chunk" and not event.text:
problems.append(f"event {n}: empty chunk")
elif event.kind != "chunk":
ended = True
return problems + ([] if ended else ["no terminal event (done or error)"])from evidencedesk.contracts import check_events
from evidencedesk.llm import LLMRequest, Message
from evidencedesk.streaming import FakeStreamClient, StreamEvent
request = LLMRequest((Message("user", "How long does standard delivery take?"),))
good = list(FakeStreamClient(chunk_size=30).stream(request))
cases = {"good": good, "interrupted": good[:2], "chunk after done": good + [StreamEvent("chunk", "x")],
"error after done": good + [StreamEvent("error", "late")], "empty chunk": [StreamEvent("chunk", ""), good[-1]]}
for name, events in cases.items():
print(f"{name:17}", check_events(events) or "ok")good ok
interrupted ['no terminal event (done or error)']
chunk after done ['event 4: chunk after the stream ended']
error after done ['event 4: error after the stream ended']
empty chunk ['event 0: empty chunk']থেমে যাওয়া স্ট্রিমে কোনো টার্মিনাল ইভেন্ট থাকে না; পরিষ্কার একটা error ইভেন্ট নিজেই টার্মিনাল। বৈধ ক্রম থেকে বোঝা যায় না উত্তরটা সঠিক কি না।
বাতিল (cancellation) ও ক্লায়েন্ট বিচ্ছিন্ন হওয়া সামলান
ব্যবহারকারী ট্যাব বন্ধ করলে সার্ভার হয়তো মডেলের স্ট্রিম পড়তেই থাকে। নীতি: পড়া বন্ধ করুন, স্ট্রিম বন্ধ করুন, আর প্রোভাইডারের দিকে কী থামে তা SDK-র ডকুমেন্টেশন থেকে জেনে নিন (VERIFY)। আপনার অংশটা পরীক্ষা করা যায়: আগে বন্ধ করা জেনারেটর তার finally চালায়। watched লিখে রাখে তার উৎস কী তৈরি করেছে আর বন্ধ হয়েছে কি না; bounded (স্ট্রিমের আকারের সীমা) থামার সময় তার উৎস বন্ধ করে।
def watched(events: Iterable[StreamEvent], log: list[str]) -> Iterator[StreamEvent]:
try:
for event in events:
log.append(event.kind)
yield event
finally:
log.append("closed")
def bounded(events: Iterable[StreamEvent], max_chars: int = MAX_CHARS) -> Iterator[StreamEvent]:
"""Reject, do not cut: past the limit send one error event, stop reading and close the source."""
total = 0
try:
for event in events:
total += len(event.text) if event.kind == "chunk" else 0
if total > max_chars:
yield StreamEvent("error", "too_large")
return
yield event
finally:
getattr(events, "close", lambda: None)()from evidencedesk.contracts import bounded, watched
log = []
events = watched(FakeStreamClient(chunk_size=10).stream(request), log)
next(events), next(events)
print("after 2 reads:", log)
events.close()
print("after close():", log)
log = []
kept = list(bounded(watched(FakeStreamClient(chunk_size=10).stream(request), log), max_chars=25))
print("bounded:", [e.kind for e in kept], "| source log:", log)after 2 reads: ['chunk', 'chunk']
after close(): ['chunk', 'chunk', 'closed']
bounded: ['chunk', 'chunk', 'error'] | source log: ['chunk', 'chunk', 'chunk', 'closed']দুবার পড়ার পর উৎস দুটো ইভেন্ট তৈরি করেছিল আর close() finally চালিয়েছে। এতে দেখা যায় আমাদের কোড তার উৎস ছেড়ে দেয়; এতে দেখা যায় না যে প্রোভাইডার জেনারেট করা বা বিল করা থামায়।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): FastAPI-র ডকুমেন্টেশনে আছে, একটা টাস্ক শুধু
await-এর জায়গায় বাতিল করা যায় (awaitছাড়া জেনারেটর "can not be cancelled properly", অর্থাৎ ঠিকভাবে বাতিল করা যায় না), আর তাদের "Stream Data" পাতা বাতিল করার কাজটা আপনার হয়ে সামলায়। Starlette স্ট্রিমিং রেসপন্সের জন্যawait request.is_disconnected()উল্লেখ করেছে; তারTestClientএকসঙ্গে রিকোয়েস্ট ও রেসপন্স স্ট্রিমিং নকল করে না, তাই আসল ডিসকানেক্ট পরীক্ষা করতে পারে না (চলমান সার্ভার ব্যবহার করুন)। Anthropic-এর ডকুমেন্টেশনে আছেwithব্লক ছাড়লে রেসপন্স বন্ধ হয়, কিন্তু সার্ভার তখনও কী বিল করে তা নেই; Gemini-র cancel কল ব্যাকগ্রাউন্ড ইন্টারঅ্যাকশনের জন্য, ফোরগ্রাউন্ড স্ট্রিমের জন্য নয়; OpenAI ও Ollama-র যে পাতাগুলো দেখা হয়েছে সেখানে স্ট্রিম আগে বাতিল করার উপায় লেখা নেই। প্রতিটাই VERIFY। বর্তমান ডকুমেন্টেশন দেখে নিন।
ইউনিকোড চাঙ্কের সীমানা পরীক্ষা করুন
পুনর্গঠন ক্ষতিহীন (lossless) হতে হবে: চাঙ্কগুলো জুড়ে মূল টেক্সটের সঙ্গে মেলান। প্রদর্শন হলো স্ক্রিন একবারে একটা চাঙ্ক দেখালে কী আঁকে। বাংলা, হিন্দি ও অ্যাকসেন্টযুক্ত ল্যাটিন একাধিক কোড পয়েন্ট দিয়ে একটা দৃশ্যমান একক বানায়, যার নাম গ্রাফিম ক্লাস্টার (grapheme cluster): কি হলো ব্যঞ্জন আর স্বরচিহ্ন, ক্ষ দুই ব্যঞ্জনকে হসন্ত (virama) দিয়ে জোড়ে, é হতে পারে e আর একটা সংযুক্ত অ্যাকসেন্ট। স্ট্যান্ডার্ড লাইব্রেরিতে পূর্ণাঙ্গ ক্লাস্টার ভাঙার ব্যবস্থা নেই (UAX #29-এর জন্য লাইব্রেরি লাগে), তাই clusters একটা আনুমানিক সমাধান; broken_boundaries গোনে কতগুলো সীমানা ক্লাস্টারের ভেতরে পড়েছে।
def clusters(text: str) -> list[str]:
"""Approximate grapheme clusters: a base plus marks, joiners and virama joins."""
out: list[str] = []
for ch in text:
last = out[-1][-1] if out else ""
mark = unicodedata.category(ch)[0] == "M" or ch in "\u200c\u200d"
if last and (mark or unicodedata.combining(last) == 9 or last == "\u200d"):
out[-1] += ch
else:
out.append(ch)
return out
def chunk_clusters(text: str, size: int) -> list[str]:
chunks, current = [], ""
for cluster in clusters(text):
if current and len(current) + len(cluster) > size:
chunks.append(current)
current = ""
current += cluster
return chunks + [current] if current else chunks
def broken_boundaries(chunks: list[str]) -> int:
return sum(len(clusters(a + b)) < len(clusters(a)) + len(clusters(b)) for a, b in zip(chunks, chunks[1:]))import unicodedata
from evidencedesk.contracts import broken_boundaries, chunk_clusters, clusters
from evidencedesk.streaming import chunk_text
texts = {"Bangla": "কিংবা ক্ষমা কার্যদিবস", "e + accent": "cafe\u0301 e\u0301te\u0301"}
for name, text in texts.items():
print(f"{name}: {len(text)} code points, {len(clusters(text))} clusters")
for size in (1, 3):
plain, safe = chunk_text(text, size), chunk_clusters(text, size)
print(f" size {size}: by characters broken={broken_boundaries(plain)}, by clusters broken={broken_boundaries(safe)},"
f" lossless={''.join(plain) == text == ''.join(safe)}")
pieces = chunk_text(texts["Bangla"], 1)[:6]
print(pieces)
print([unicodedata.name(p) for p in pieces if unicodedata.category(p)[0] == "M"])Bangla: 21 code points, 11 clusters
size 1: by characters broken=10, by clusters broken=0, lossless=True
size 3: by characters broken=2, by clusters broken=0, lossless=True
e + accent: 11 code points, 8 clusters
size 1: by characters broken=3, by clusters broken=0, lossless=True
size 3: by characters broken=0, by clusters broken=0, lossless=True
['ক', 'ি', 'ং', 'ব', 'া', ' ']
['BENGALI VOWEL SIGN I', 'BENGALI SIGN ANUSVARA', 'BENGALI VOWEL SIGN AA']অক্ষর ধরে ভাঙা প্রতিবারই ক্ষতিহীন (টুকরোগুলো একই স্ট্রিংয়ের স্লাইস), তবু বাংলা টেক্সটে ১০টা সীমানা ক্লাস্টারের ভেতরে পড়ে; দ্বিতীয় টুকরোটা একা BENGALI VOWEL SIGN I। যে পাতা প্রতিটা চাঙ্ক আলাদা এলিমেন্টে আঁকে, সেখানে এমন একটা স্বরচিহ্ন দেখা যায় যার সংযুক্ত হওয়ার কিছু নেই। আমরা সীমানা মাপি, পিক্সেল নয়। ক্লাস্টার-সচেতন চাঙ্কে এটা ০। তাই ফ্রন্ট এন্ড চাঙ্কগুলো একটাই স্ট্রিংয়ে জুড়ে সেটাই আঁকে; পরীক্ষায় অনেক চাঙ্ক আকার চেষ্টা করুন, কারণ ভাগ্যক্রমে একটা সীমানা নিরীহ হতে পারে (অ্যাকসেন্ট টেক্সটে আকার ৩); আর টেক্সট তুলনা করুন unicodedata.normalize করার পর, কারণ é একটা কোড পয়েন্টে বা দুটোয় লিখলে == তাদের আলাদা ধরে।
আউটপুটের আকার সীমিত করুন
তিনটা সীমা: একটা উত্তর (MAX_BYTES, পার্স করার আগে যাচাই), একটা স্ট্রিম (bounded), আর স্কিমার ফিল্ড (উত্তর আগের মতো ১০০০ অক্ষর পর্যন্ত; সর্বোচ্চ ৫টা আইডি ও ৫টা লিংক; আইডি নিরাপদ প্যাটার্নের; লিংক ২০০ অক্ষর পর্যন্ত)। সীমা ছাড়ালে আপনি বাতিল করতে বা কেটে চিহ্নিত করতে পারেন। আমরা বাতিল করি: কাটা উত্তর শেষের "না" বা সতর্কবার্তা হারাতে পারে, কাটা তালিকা সাইটেশন ফেলে দেয়, আর কাটা JSON এমনিতেই অবৈধ। বাতিল হওয়া উত্তর একটা নিরাপদ এরর হয়, এমন কোড সহ যা আপনি গুনতে পারেন (too_large, নিচের ফিক্সচারেও আছে)। কেটে-চিহ্নিত করা মানায় শুধু দৃশ্যমান কাটা সহ সাধারণ টেক্সটে।
URL ও আইডেন্টিফায়ারকে ডেটা হিসেবে যাচাই করুন
স্কিমা গড়ন দেখে, অর্থ নয়। মডেল এমন আইডি দিতে পারে যা সে কখনো দেখেনি, বা অন্য সাইটের একটা বৈধ URL; রিট্রিভ করা টেক্সটও তাকে সেদিকে ঠেলতে পারে (দেখুন কনটেক্সট সাজানো ও বিশ্বাসের সীমানা)। দুটো যাচাই, শুধু টেক্সটের ওপর, কোনো নেটওয়ার্ক কল নয়: উদ্ধৃত আইডি অবশ্যই এই রিকোয়েস্টের জন্য রিট্রিভ করা সেটের মধ্যে থাকবে ("কর্পাসে কোথাও আছে" তার চেয়ে কড়া), আর প্রতিটা লিংককে check_url পার হতে হবে: স্কিম https, কোনো ক্রেডেনশিয়াল নয়, হোস্ট হুবহু অ্যালাউলিস্টে। urlsplit ব্যবহার করুন: startswith ধোঁকা খায় help.kestrelcycles.example.evil.test আর help.kestrelcycles.example@evil.test দিয়ে।
def check_url(url: str, hosts: frozenset[str] = HOSTS) -> str | None:
if not url or any(c.isspace() or not c.isprintable() for c in url):
return "empty, or has whitespace or control characters"
try:
parts = urlsplit(url)
host = parts.hostname
except ValueError:
return "not a valid URL"
if parts.scheme != "https":
return f"scheme {parts.scheme or '(none)'!r} is not https"
if parts.username or parts.password:
return "credentials in the URL"
return None if host in hosts else f"host {host!r} is not allowed"
def untrusted_problems(answer: Answer, retrieved: Iterable[str], hosts: frozenset[str] = HOSTS) -> list[str]:
"""Ids must be ones retrieved for THIS question; links must be on our hosts."""
known = set(retrieved)
found = [f"cited id {i!r} was not retrieved" for i in answer.cited_ids if i not in known]
return found + [f"link {u[:30]!r}: {why}" for u in answer.links if (why := check_url(u, hosts))]from evidencedesk.contracts import check_url, untrusted_problems
urls = ["https://help.kestrelcycles.example/returns", "http://help.kestrelcycles.example/returns", "javascript:alert(1)",
"https://help.kestrelcycles.example.evil.test/", "https://help.kestrelcycles.example@evil.test/"]
for url in urls:
print(f"{url:50} {check_url(url) or 'allowed'}")
answer = read_answer({**v2, "cited_ids": ["kc-returns-v3", "kc-privacy-v1"], "links": ["http://evil.test/x"]}).value
for problem in untrusted_problems(answer, retrieved={"kc-returns-v3"}):
print(problem)https://help.kestrelcycles.example/returns allowed
http://help.kestrelcycles.example/returns scheme 'http' is not https
javascript:alert(1) scheme 'javascript' is not https
https://help.kestrelcycles.example.evil.test/ host 'help.kestrelcycles.example.evil.test' is not allowed
https://help.kestrelcycles.example@evil.test/ credentials in the URL
cited id 'kc-privacy-v1' was not retrieved
link 'http://evil.test/x': scheme 'http' is not httpsপ্রতিটা চালাকি আলাদা কারণে ব্যর্থ হয়; শেষ দুই লাইন হলো লগে রাখার মতো রিপোর্ট। অনুমোদিত লিংক প্রমাণ করে না যে পাতাটা দাবিকে সমর্থন করে, আর সার্ভার কখনো মডেলের দেওয়া URL ফেচ করবে না।
প্রোভাইডারের আউটপুট চুক্তি তুলনা করুন
প্রোভাইডারদের মধ্যে তফাত থাকে এসব জায়গায়: টেক্সট কোথায় থাকে, কেটে যাওয়া বা প্রত্যাখ্যান কীভাবে জানানো হয়, আর usage ফিল্ডের নাম কী। একটা অ্যাডাপ্টার একটা গড়নকে আমাদের LLMResponse-এ রূপান্তর করে; তারপর একটাই পার্সার আর একটাই স্কিমা সবার কাজ করে। নিচের দুটো ডিকশনারি হাতে লেখা ও সরলীকৃত, আসল রেসপন্স নয়। অচেনা সংকেত হয়ে যায় other, যা parse_contract বাতিল করে: নিরাপদ ডিফল্ট।
def from_shape_a(raw: dict) -> LLMResponse:
# hand-written shape A: output_text, status, usage
cut = raw.get("incomplete_details", {}).get("reason") == "max_output_tokens"
stop = "end" if raw.get("status") == "completed" else "length" if cut else "other"
return LLMResponse(raw.get("output_text", ""), "shape-a", Usage(**raw.get("usage", {})), stop)
def from_shape_b(raw: dict) -> LLMResponse:
# hand-written shape B: content list, stop_reason, tokens (a refusal becomes "other")
text = "".join(b.get("text", "") for b in raw.get("content", []) if b.get("type") == "text")
stop = {"end_turn": "end", "max_tokens": "length"}.get(raw.get("stop_reason"), "other")
t = raw.get("tokens", {})
return LLMResponse(text, "shape-b", Usage(t.get("prompt", 0), t.get("completion", 0)), stop)import json
from evidencedesk.contracts import from_shape_a, from_shape_b, parse_contract
body = json.dumps(v2)
# hand-written and simplified: these two dicts are NOT copies of any real provider response
shape_a = {"status": "completed", "output_text": body, "usage": {"input_tokens": 41, "output_tokens": 19}}
shape_b = {"content": [{"type": "text", "text": body}], "stop_reason": "end_turn", "tokens": {"prompt": 41, "completion": 19}}
a, b = from_shape_a(shape_a), from_shape_b(shape_b)
print("same usage:", a.usage == b.usage, "| same value:", parse_contract(a).value == parse_contract(b).value)
cut_a = {**shape_a, "status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}}
for name, response in {"a: cut off": from_shape_a(cut_a), "b: refusal": from_shape_b({**shape_b, "stop_reason": "refusal"})}.items():
print(f"{name:11} stop={response.stop_reason:7} -> {parse_contract(response).code}")same usage: True | same value: True
a: cut off stop=length -> truncated
b: refusal stop=other -> unusable_stopপ্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): Anthropic-এর কাঠামোবদ্ধ আউটপুটের সাবসেট
minLength/maxLengthসমর্থন করে না, তাই আকারের সীমা আপনারই থাকে। পার্স করার আগে দেখুন: OpenAI রেসপন্সেrefusalকনটেন্ট অংশ থাকতে পারে (JSON হিসেবে পার্স করবেন না) অথবাstatus == "incomplete"সহincomplete_details.reason == "max_output_tokens"; Anthropic-এরstop_reasonহতে পারেrefusal(200 রেসপন্স, স্কিমার সঙ্গে না-ও মিলতে পারে) বাmax_tokens; Gemini-রfinishReason-এSTOPছাড়া অন্য মান আছে, যা অন্ধভাবে পার্স করা উচিত নয়; Ollama-রdone_reasonআছে (মানগুলো নিশ্চিত নয়)। আপনার SDK সংস্করণের ফিল্ডগুলো VERIFY করুন। বর্তমান ডকুমেন্টেশন দেখে নিন।
ব্যর্থতার জন্য রিগ্রেশন ফিক্সচার লিখুন
যে খারাপ উত্তরের মুখোমুখি হবেন, ফাইলে তার জন্য একটা লাইন রাখা উচিত। রিগ্রেশন ফিক্সচার উত্তরটা (raw টেক্সট, বা একটা ঠিক উত্তরের ওপর patch), তার স্টপ রিজন এবং প্রত্যাশিত কোড রেখে দেয়, যাতে কোনো পরিবর্তন নিঃশব্দে ব্যর্থতাটা ফিরিয়ে আনতে না পারে। আগে লাইনটা যোগ করুন, ব্যর্থ হতে দেখুন, তারপর কোড ঠিক করুন। JSON Lines, প্রতি লাইনে একটা অবজেক্ট:
{"name": "v1 good", "patch": {"schema_version": 1}, "expect": "ok"}
{"name": "future version", "patch": {"schema_version": 3}, "expect": "unknown_version"}
{"name": "prose, not JSON", "raw": "Sure! It is 30 days.", "expect": "invalid_json"}
{"name": "cut mid-string, stop says end", "raw": "{\"schema_version\": 2, \"answer\": \"30 da", "expect": "invalid_json"}
{"name": "cut off, stop says length", "raw": "{\"schema_version\": 2, \"answer\": \"30 da", "stop": "length", "expect": "truncated"}
{"name": "refusal-like stop", "raw": "", "stop": "other", "expect": "unusable_stop"}
{"name": "unexpected extra key", "patch": {"debug": true}, "expect": "invalid_schema"}
{"name": "path-like id", "patch": {"cited_ids": ["../secret"]}, "expect": "invalid_schema"}
{"name": "reply over the byte limit", "patch": {}, "max_bytes": 60, "expect": "too_large"}replay একটা লাইনের কোড ফেরত দেয়, আর কিটের টেস্ট প্রতিটা লাইনকে একটা pytest কেস বানায় (নিচের কঠিন অনুশীলনে নতুন লাইন রিপ্লে করা হবে)। ফাইলটা একটা ভিত, প্রমাণ নয়: এতে শুধু সেই ব্যর্থতাগুলোই আছে যা আপনি দেখেছেন।
def load_failures(path: Path = FAILURES) -> list[dict]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def replay(case: dict) -> str:
"""A case has `raw` text or a `patch` on GOOD, plus optional `stop` and `max_bytes`."""
text = case["raw"] if "raw" in case else json.dumps({**GOOD, **case["patch"]})
response = LLMResponse(text, "recorded", Usage(), case.get("stop", "end"))
return parse_contract(response, case.get("max_bytes", MAX_BYTES)).codeমিনি-ল্যাব: স্ট্রিমিং কাঠামোবদ্ধ FAQ এন্ডপয়েন্ট
এবার একটা এন্ডপয়েন্ট, make_app প্যাটার্ন বাড়িয়ে। অর্ধেক JSON টেক্সট বৈধ নয়, তাই কাঠামোবদ্ধ উত্তর স্ট্রিম হওয়ার সময় দেখানো যায় না। তাই সার্ভার স্ট্রিম জমা করে (সীমার ভেতরে), যাচাই করে (সংস্করণ, স্কিমা, আইডি, লিংক) এবং তারপরই যাচাই করা টেক্সট চাঙ্ক আকারে পাঠায়, আর পাঠায় ঠিক একটা টার্মিনাল ইভেন্ট যাতে পুরো Envelope থাকে। এর মূল্য: প্রথম চাঙ্ক আসতে দেরি হয়। স্ট্যাটাস সবসময় 200 (সমস্যা জানার আগেই সেটা পাঠানো হয়ে যায়), তাই ব্যর্থতা মানে একটা একক error ইভেন্ট, যার এনভেলপে অবস্থা ও কোড থাকে।
def contract_from_stream(streamed: Assembled, retrieved: Iterable[str]) -> Envelope:
"""Half a JSON text is never shown, so a broken structured stream is an error."""
if streamed.status == "empty":
return to_envelope(streamed)
if streamed.status != "complete":
code = "too_large" if streamed.error == "too_large" else streamed.status.replace("error", "stream_error")
return to_envelope(Outcome(False, code))
outcome = parse_contract(LLMResponse(streamed.text, "stream", streamed.usage or Usage(), streamed.stop_reason or "other"))
if outcome.ok and (found := untrusted_problems(outcome.value, retrieved)):
outcome = Outcome(False, "untrusted_value", problems=tuple({"loc": "", "msg": f} for f in found))
return to_envelope(outcome)
def envelope_sse(envelope: Envelope) -> Iterator[str]:
if envelope.state in ("ok", "partial"):
for piece in chunk_clusters(envelope.text, 16):
yield f"event: chunk\ndata: {json.dumps({'text': piece}, ensure_ascii=False)}\n\n"
kind = "done" if envelope.state in ("ok", "empty") else "error"
yield f"event: {kind}\ndata: {envelope.model_dump_json()}\n\n"
def make_faq_app(client, retrieved: Iterable[str], max_chars: int = MAX_CHARS):
from fastapi import FastAPI, Query
from fastapi.responses import StreamingResponse
app, known = FastAPI(), frozenset(retrieved)
@app.get("/faq")
def faq(q: str = Query(min_length=1, max_length=500)) -> StreamingResponse:
events = bounded(safe_events(client, LLMRequest((Message("user", q),))), max_chars)
envelope = contract_from_stream(assemble(events), known)
return StreamingResponse(envelope_sse(envelope), media_type="text/event-stream")
return appফেক মডেল একটা স্ট্যান্ড-ইন (আসল মডেল কম অনুমানযোগ্য, আর ঠিক সে কারণেই পরীক্ষা করতে হয়)। পাঁচটা পরিস্থিতি:
from fastapi.testclient import TestClient
from evidencedesk.contracts import check_events, make_faq_app
from evidencedesk.streaming import parse_sse
Q = "How long do I have to return a new bike?"
bn = {**v2, "answer": "নতুন বাইক ৩০ দিনের মধ্যে ফেরত দেওয়া যায়।", "cited_ids": ["kc-returns-v3-bn"]}
lab = [("good v2", v2, {}), ("Bangla answer", bn, {}), ("id not retrieved", {**v2, "cited_ids": ["kc-privacy-v1"]}, {}),
("dropped stream", v2, {"stop_after": 2}), ("error mid-stream", v2, {"fail_after": 2})]
print(f"{'case':18} status chunks last {'state/code':22} check")
for name, payload, options in lab:
fixtures = {Q: {"text": json.dumps(payload, ensure_ascii=False), "usage": {}, "stop_reason": "end"}}
client = TestClient(make_faq_app(FakeStreamClient(fixtures, chunk_size=9, **options), {"kc-returns-v3", "kc-returns-v3-bn"}))
with client.stream("GET", "/faq", params={"q": Q}) as response:
lines = list(response.iter_lines())
events = parse_sse(lines)
final = json.loads([ln for ln in lines if ln.startswith("data: ")][-1][6:]) # the envelope in the terminal event
print(f"{name:18} {response.status_code:6} {sum(e.kind == 'chunk' for e in events):6} {events[-1].kind:5}"
f" {final['state'] + '/' + final['code']:22} {check_events(events) or 'ok'}")case status chunks last state/code check
good v2 200 3 done ok/ok ok
Bangla answer 200 3 done ok/ok ok
id not retrieved 200 0 error error/untrusted_value ok
dropped stream 200 0 error error/interrupted ok
error mid-stream 200 0 error error/stream_error okভালো উত্তর ও বাংলা উত্তর তিনটা চাঙ্ক স্ট্রিম করে done দিয়ে শেষ হয়। প্রতিটা ব্যর্থতায় একটাও চাঙ্ক যায় না (অর্ধেক উত্তর নেই, যাচাই-না-হওয়া লিংক বা আইডি নেই) আর তার কোড সহ একটা error ইভেন্ট যায়; check_events কোনো ভাঙা নিয়ম পায় না। tests/test_contracts.py এটা, সঙ্গে সংস্করণ, URL, অ্যাডাপ্টার, ইউনিকোড ও ফিক্সচারের কেসও যাচাই করে:
python -m pytest -q tests/test_contracts.py | sed 's/ in [0-9.]*s//'........................................................ [100%]
56 passedএটাই পাস লাইন; sed সেকেন্ডগুলো সরিয়েছে। এতে দেখা যায় না আসল স্ট্রিম টেক্সট কীভাবে ভাঙে, কোনো SDK বাতিল করে কি না, বা ব্রাউজার কী আঁকে।
কাজের জায়গায় এটা কোথায় দেখা যায়
ফ্রন্ট এন্ডের সহকর্মী চান "স্থিতিশীল রেসপন্স", প্রোডাক্ট ম্যানেজার জানতে চান ব্যর্থ হলে ব্যবহারকারী কী দেখবেন, নিরাপত্তা রিভিউয়ার জানতে চান লিংক কোথা থেকে আসে। এনভেলপ, অ্যালাউলিস্ট ও ফিক্সচার তিনটারই উত্তর দেয়, আর বার্লিনের লেনাকে পরিবর্তন অনুমোদনের আগে গতকালের ঘটনাগুলো রিপ্লে করতে দেয়।
সাধারণ ভুল
- সংস্করণ ছাড়া ফিল্ড বদলানো। পুরোনো ক্লায়েন্ট নিঃশব্দে ভেঙে যায়। সংস্করণ বাড়ান, পুরোনো রিডার রাখুন।
- আংশিক কাঠামোবদ্ধ আউটপুট দেখানো। কাটা JSON কোনো উত্তর নয়। আংশিক টেক্সট শুধু সাধারণ টেক্সটের জন্য, চিহ্নিত করে।
- একাধিক টার্মিনাল ইভেন্ট, বা একটাও নেই। ক্লায়েন্ট চিরকাল অপেক্ষা করে বা দুটো ফল দেখায়। পরীক্ষায় ক্রম যাচাই করুন।
- ইউনিকোড শুধু একটা চাঙ্ক আকারে পরীক্ষা করা। সীমানা ভাগ্যক্রমে ঠিক হতে পারে। অনেক আকার চেষ্টা করুন, বাংলা সহ।
- বৈধ আইডি বা URL-কে বিশ্বাস করা। পাস করা স্কিমা বলে না মানটা কোথায় নিয়ে যায়। অ্যালাউলিস্ট ব্যবহার করুন, আর সীমা ছাড়ালে নিঃশব্দে না কেটে বাতিল করুন।
নিজে চেষ্টা করুন
- সহজ। আগে অনুমান করুন, তারপর
https://help.kestrelcycles.example:8443/x,HTTPS://help.kestrelcycles.example/ওhttps://help.kestrelcycles.example./x-এcheck_urlচালান। কোনগুলো অনুমোদিত? - মাঝারি। স্ট্রিমিং ও কাঠামোবদ্ধ ফলাফল আবার দেখুন: ডেলিভারির প্রশ্ন
stop_after=1ওfail_after=1দিয়ে স্ট্রিম করুন।check_eventsআরto_envelope(assemble(...))কী বলে? দুটো আলাদা কেন? - কঠিন। তিনটা ফিক্সচার কেস লিখুন (ছয়টা আইডি; খালি উত্তর; সংস্করণ ৩), তাদের কোড আগে অনুমান করুন, তারপর রিপ্লে করুন।
উত্তর
সহজ। প্রথম দুটো অনুমোদিত: পোর্ট হোস্টের অংশ নয়, আর urlsplit স্কিম ও হোস্ট ছোট হাতের করে দেয়। তৃতীয়টা প্রিন্ট করে host 'help.kestrelcycles.example.' is not allowed: শেষের ডট সেটাকে আলাদা স্ট্রিং বানায়, আর প্রত্যাখ্যান করাই নিরাপদ ডিফল্ট।
মাঝারি। থেমে যাওয়া স্ট্রিমে টার্মিনাল ইভেন্ট নেই, তাই check_events আপত্তি করে আর এনভেলপ partial। ব্যর্থ স্ট্রিম শেষ হয় একটা error ইভেন্টে, যা বৈধ টার্মিনাল ইভেন্ট, তাই ক্রম বৈধ কিন্তু এনভেলপ error। একটা যাচাই জিজ্ঞেস করে "স্ট্রিমটা কি সঠিক গঠনের?", অন্যটা "ব্যবহারকারী কী দেখতে পারবেন?"।
কঠিন। ছয়টা আইডি তালিকার সীমা ভাঙে, খালি উত্তর আগের পাতার ন্যূনতম দৈর্ঘ্য ভাঙে, আর সংস্করণ ৩ অজানা। এমন লাইন ফিক্সচার ফাইলে যোগ করলে কিটের টেস্ট অপরিবর্তিত অবস্থায় সেগুলো রিপ্লে করে।
from evidencedesk.contracts import replay
new = [{"name": "six ids", "patch": {"cited_ids": ["a"] * 6}, "expect": "invalid_schema"},
{"name": "empty answer", "patch": {"answer": ""}, "expect": "invalid_schema"},
{"name": "version 3", "patch": {"schema_version": 3}, "expect": "unknown_version"}]
for case in new:
print(f"{case['name']:15} expect={case['expect']:15} got={replay(case)}")six ids expect=invalid_schema got=invalid_schema
empty answer expect=invalid_schema got=invalid_schema
version 3 expect=unknown_version got=unknown_versionপাস চেকলিস্ট:
- সহজ: অনুমোদিত, অনুমোদিত, প্রত্যাখ্যাত, আর কারণ বলতে পারেন (নিশ্চিত হতে চালিয়ে দেখুন)।
- মাঝারি:
stop_afterআপত্তি আরpartialদেয়;fail_afterবৈধ ক্রম আরerrorদেয়। - কঠিন: প্রতিটা প্রাপ্ত কোড আপনার অনুমান করা কোডের সমান, যা রিপ্লের আগে লেখা।
- ল্যাব: টেস্টগুলো পাস করে আর প্রতিটা ব্যর্থতা একটা স্থির বার্তা সহ এক-ইভেন্টের স্ট্রিম।
সারসংক্ষেপ
- স্কিমার সংস্করণ দিন, জানা সংস্করণ একটাই রিডারে পড়ুন, বাকিগুলো বাতিল করুন; ফ্রন্ট এন্ডকে প্রতিটা অবস্থার জন্য একটাই
Envelopeদিন। - স্ট্রিমে চাঙ্ক আর ঠিক একটা টার্মিনাল ইভেন্ট থাকে। বাতিলের জন্য পড়া থামান ও উৎস বন্ধ করুন; প্রোভাইডারের দিকটা VERIFY।
- অক্ষর ধরে পুনর্গঠন ক্ষতিহীন, কিন্তু প্রদর্শনে পুরো ক্লাস্টার লাগে।
- সীমা ছাড়ালে বাতিল করুন। ফেরত আসা আইডি ও URL অবিশ্বস্ত: অ্যালাউলিস্ট, নেটওয়ার্ক কল নয়।
- অ্যাডাপ্টার প্রোভাইডারের গড়নকে একটা স্কিমায় আনে; ফিক্সচার ব্যর্থতা ধরে রাখে।
এরপর: এমবেডিং ও সাদৃশ্য EvidenceDesk-এর রিট্রিভাল অংশ শুরু করে: টেক্সটকে ভেক্টরে বদলায়, যাতে একটা প্রশ্ন একটা অনুচ্ছেদের কতটা কাছাকাছি তা মাপা যায়।