الوحدة 5 — المخرجات المنظَّمة: JSON والمخطّطات
انتهت الوحدات السابقة إلى تعليمة تُنتج JSON قابلًا للتحليل في تسع من كلّ عشر محاولات. الرقم مغرٍ، لكنّه غير كافٍ لبناء نظام إنتاجي: احتمال 10% لكسر أنبوب المعالجة عند كلّ نداء يعني تعطّلًا متواصلًا. تُقدّم هذه الوحدة الأدوات التي تدفع النسبة إلى 100% تقريبًا: أوضاع JSON الأصلية، والمخطّطات المحقونة، والتصديق مع إعادة محاولة ذكيّة.
من «أعِد JSON فقط» إلى الأوضاع الأصلية
الصياغة النصّية «أعِد JSON فقط» تعتمد على انضباط النموذج، وهو غير كامل. لذلك أطلق المزوّدون أوضاع JSON الأصلية التي تضمن أنّ المُخرَج نصّ JSON صالح تركيبيًّا.
في openai مثلًا:
r = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_object"},
messages=[...],
)
هذا الوضع يُقيّد فكّ الترميز ليختار فقط جَتونات تحافظ على صحّة JSON. النتيجة: 100% من المخرجات صالحة نحويًّا. لكن لاحظ: الوضع يضمن الصحّة النحويّة، لا الصحّة الدلاليّة. النموذج قد يُنتج JSON بحقول غير التي طلبتها، أو بقيم من نوع خاطئ.
المخطّطات المحقونة: Structured Outputs
الجيل التالي من الأوضاع، مثل response_format={"type": "json_schema"} في openai، يقبل json-schema ويُقيّد المُخرَج بكامل بنيته: الحقول المطلوبة، وأنواعها، وقيمها المحدَّدة، وحدود الأعداد. النموذج لا يستطيع بنيويًّا أن يُخرج شيئًا لا يطابق المُخطَّط.
from openai import OpenAI
client = OpenAI()
schema = {
"type": "object",
"properties": {
"sujet": {"type": "string", "enum": ["retard", "defaut", "facture", "autre"]},
"produit": {"type": ["string", "null"]},
"urgence": {"type": "string", "enum": ["faible", "moyenne", "haute"]},
"action": {"type": ["string", "null"]},
},
"required": ["sujet", "produit", "urgence", "action"],
"additionalProperties": False,
}
r = client.chat.completions.create(
model="gpt-4o-2024-08-06",
response_format={
"type": "json_schema",
"json_schema": {"name": "email_extraction", "schema": schema, "strict": True},
},
messages=[
{"role": "system", "content": "استخرِج الحقول من رسالة العميل."},
{"role": "user", "content": email},
],
)
مع strict: true، المُخرَج مضمون أن يحتوي الحقول الأربعة، بالأنواع الصحيحة، مع sujet من القائمة المحدَّدة. لا حاجة لإعادة محاولة على أخطاء تنسيقيّة، لأنّها صارت مستحيلة.
تصديق ذكي مع pydantic
المُخطَّط الخام يضمن البنية النحويّة. لكن نريد غالبًا قيودًا دلاليّة: طول الرسالة لا يتجاوز 200 حرف، والاستعجال يتّسق مع كلمات المُدخَل، وما إلى ذلك. مكتبة pydantic تُعطي هذا الحقل:
from pydantic import BaseModel, Field
from typing import Literal, Optional
class ExtractionEmail(BaseModel):
sujet: Literal["retard", "defaut", "facture", "autre"]
produit: Optional[str] = Field(None, max_length=100)
urgence: Literal["faible", "moyenne", "haute"]
action: Optional[str] = Field(None, max_length=200)
def extraire(email: str) -> ExtractionEmail:
r = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEME},
{"role": "user", "content": email},
],
)
return ExtractionEmail.model_validate_json(r.choices[0].message.content)
model_validate_json يرفع استثناء ValidationError إن كانت البنية أو الأنواع مخالفة، ويعطينا كائنًا مُوثَّق النوع نُمرِّره إلى بقيّة الشيفرة بأمان.
pydantic يتكامل كذلك مع openai عبر beta.chat.completions.parse، الذي يحقن المُخطَّط من الصنف مباشرةً ويُعيد نسخة من الكائن:
r = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
response_format=ExtractionEmail,
messages=[...],
)
extraction: ExtractionEmail = r.choices[0].message.parsed
هذا هو الشكل الموصى به لأنّه يجمع ضمان البنية على مستوى فكّ الترميز مع تصديق دلالي على مستوى Python.
إعادة المحاولة الذكيّة
حتّى مع أوضاع JSON الأصلية، تُنتِج نداءات إعادة الاتّصال أخطاء أخرى: مهلات، وأخطاء 5xx، ورفض المحت وى. الإعداد الأدنى لكلّ استدعاء إنتاجي:
from openai import OpenAI, RateLimitError, APIError
import time
client = OpenAI()
def extraire_avec_reprise(email: str, max_essais: int = 3):
for essai in range(max_essais):
try:
r = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
response_format=ExtractionEmail,
messages=[
{"role": "system", "content": SYSTEME},
{"role": "user", "content": email},
],
)
return r.choices[0].message.parsed
except (RateLimitError, APIError) as e:
if essai == max_essais - 1:
raise
time.sleep(2 ** essai) # تراجع أُسّي
raise RuntimeError("échec après plusieurs essais")
القاعدة الذهبية: تراجع أُسّي مع حدّ أقصى للمحاولات. لا تُعِد المحاولة على أخطاء التصديق (ValidationError) بلا تعديل، لأنّها ستُعطي النتيجة نفسها؛ في هذه الحالة، أعِد النداء بتعليمة إضافيّة تذكر الخطأ:
try:
resultat = ExtractionEmail.model_validate_json(reponse_texte)
except ValidationError as e:
messages.append({"role": "assistant", "content": reponse_texte})
messages.append({"role": "user", "content": f"مُخرَجك السابق كسر المُخطَّط. الخطأ: {e}. أعِد الجواب مطابقًا للمُخطَّط."})
# نداء ثانٍ
الحقول الاختيارية والقيم غير المعروفة
سؤال متكرّر: كيف نُميّز بين «لم يذكر الحقل» و«الحقل موجود لكنّه فارغ»؟ الحلّ الاصطلاحي في JSON هو null للأولى، وسلسلة فارغة أو قيمة افتراضية للثانية. لكنّ النماذج تخلط بين الأمرين إن لم يكن الفرق واضحًا في التعليمة والمُخطَّط.
القاعدة العملية: اجعل الحقل قابلًا للـ null حين تكون الغيبة معنى صحيحًا، ووثِّق ذلك صراحةً في التعليمة: «إن لم يكن المنتَج مذكورًا صراحةً في الرسالة، اكتب null. لا تُخمِّن». وفي المُخطَّط: "produit": {"type": ["string", "null"]}.
ماذا حين لا يوجد وضع منظَّم؟
بعض النماذج المفتوحة لا تدعم أوضاع JSON الأصلية، وبعض المزوّدين لا يمرّرون خيار json_schema. الحلّ حينها هو الاستخراج من النصّ الحرّ: نطلب من النموذج إحاطة JSON بعلامات محدَّدة، ثم نستخرجه بتعبير منتظم.
import re, json
def extraire_json_du_texte(texte: str):
match = re.search(r"```json\s*(\{.*?\})\s*```", texte, re.DOTALL)
if not match:
raise ValueError("لا يوجد كتلة JSON في المُخرَج")
return json.loads(match.group(1))
هذا الحلّ أقلّ صلابة بلا شكّ من الأوضاع الأصلية، لكنّه يعمل بشكل مقبول مع نماذج مثل mistral وllama عبر مزوّدين لا يقدّمون التقييد النحوي. القاعدة العملية هنا: أضِف تعليمة صريحة في تعليمة النظام تطلب صراحةً استعمال الفاصلة، وذكِّرها في تعليمة المستخدم عند الحاجة، فالنموذج يميل إلى نسيانها في المخرجات الطويلة. راقب معدّل النجاح على جملة اختبار حقيقية قبل أن تعتمد النموذج المفتوح؛ الفرق بينه وبين وضع أصلي قد يكون خمسة أو عشرة بالمئة، وهذا فارق حاسم في الإنتاج.