انتقل إلى المحتوى الرئيسي

الوحدة 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 عبر مزوّدين لا يقدّمون التقييد النحوي. القاعدة العملية هنا: أضِف تعليمة صريحة في تعليمة النظام تطلب صراحةً استعمال الفاصلة، وذكِّرها في تعليمة المستخدم عند الحاجة، فالنموذج يميل إلى نسيانها في المخرجات الطويلة. راقب معدّل النجاح على جملة اختبار حقيقية قبل أن تعتمد النموذج المفتوح؛ الفرق بينه وبين وضع أصلي قد يكون خمسة أو عشرة بالمئة، وهذا فارق حاسم في الإنتاج.

قواعد عملية لاختيار نموذجك المنتج

في مشروع جديد، اختر نموذجًا يدعم أوضاع json_schema أصلية إن أمكن. الفارق الاقتصادي بين gpt-4o-mini وgpt-4o-2024-08-06 (الذي يدعم الوضع الصارم) ضئيل مقارنة بكلفة إعادة المحاولات والأخطاء التي تُنتجها نماذج بدون تقييد نحوي. الحسبة تُرجّح غالبًا النموذج الذي يُقلِّل شغل البنية التحتية حتى لو كانت وحدة الجَتون أغلى قليلًا. أضِف إلى ذلك أنّ الوقت الذي يوفّره مطوّرك على تصحيح مخرجات مكسورة يُبرِّر الفرق مرارًا.

strict: true لا يمنع القيم الخاطئة دلاليًّا

json_schema بـ strict: true يضمن أنّ الحقل sujet سيكون من ["retard", "defaut", "facture", "autre"]. لكنّه لا يضمن أنّه سيكون الصنف الصحيح. النموذج قد يُصنِّف رسالة عن الفاتورة بـ retard. المُخطَّط يحمي البنية، والتقييم (الوحدة 9) يحمي الدلالة.

abordez le schéma comme un contrat

اعتبر مُخطَّط JSON عقدًا بينك وبين النموذج، وبينك وبين بقيّة نظامك. غيّره فقط عبر نسخة (v1, v2)، ولا تحذف حقلًا دون فترة انتقال. الأنظمة التي تستقبل مُخرَج النموذج تعتمد على هذا العقد بصمت، وإخلاله يُحدث أعطالًا يصعب تتبّعها.

في الخلاصة

  • الأوضاع الأصلية (json_object, json_schema) تضمن صحّة JSON نحويًّا؛ استخدمها كلّما توفّرت.
  • json-schema strict يفرض البنية والأنواع والقيم المحدَّدة على مستوى فكّ الترميز.
  • pydantic يُضيف تصديقًا دلاليًّا وأنواعًا موثَّقة في شيفرة Python.
  • إعادة المحاولة تكون بتراجع أُسّي على أخطاء الشبكة، وبتعديل التعليمة على أخطاء التصديق.

الوحدة التالية: التحكّم في الأسلوب والطول والنبرة بقيود قابلة للقياس، بعد أن أمّنّا شكل المُخرَج.