یادداشت‌های بک‌اند ۴: تبدیل PDF به LaTeX با یک مدل زبانی در چرخه

یادداشت‌های بک‌اند ۴: تبدیل PDF به LaTeX با یک مدل زبانی در چرخه

فایل PDF آزمون برای چاپ ساخته شده است. صفحه‌ای که به چشم دانش‌آموز مرتب است،‏ برای برنامه مشتی نویسه،‏ تکه‌خط و تصویر است و هیچ نشانه‌ای ندارد که یک سؤال کجا تمام می‌شود و سؤال بعدی کجا شروع. بانک سؤال هر سؤال را به‌شکل یک رکورد لازم دارد: صورت سؤال،‏ گزینه‌ها،‏ پاسخ،‏ شکل‌ها و فراداده‌هایی مثل موضوع،‏ مهارت و سطح دشواری،‏ تا بشود سؤال‌ها را جست‌وجو کرد،‏ از آن‌ها آزمون ساخت و بعدها کالیبره‌شان کرد.

سال ۲۰۲۴ در یک قرارداد دورکاری با شرکت استرالیایی Noraco،‏ خط پردازشی را خودکار کردم که آزمون‌های مکتوب را به سؤال‌های ساخت‌یافته و آمادهٔ ورود به پایگاه‌داده تبدیل می‌کرد؛‏ روی هم بیش از ۵٬۰۰۰ سؤال. این خط پردازش PDF را به LaTeX تبدیل می‌کرد،‏ از طریق OpenRouter با APIهای مدل‌های زبانی برچسب موضوع،‏ مهارت و دشواری می‌ساخت،‏ و به سرویس‌های FastAPI و یک کنسول بازبینی با React برای اعتبارسنجی LaTeX،‏ کنترل کیفیت و انتشار ختم می‌شد. این یادداشت دربارهٔ این است که چنین خط پردازشی معمولاً چطور ساخته می‌شود،‏ مرحلهٔ برچسب‌گذاری را چطور می‌بینم و چرا بازبینی انسانی اختیاری نیست.

مرحله‌ها

  1. دریافت. فایل اصلی ذخیره و برای پیدا کردن تکراری‌ها هش می‌شود و منبعش هم ثبت می‌شود: کدام آزمون،‏ کدام سال،‏ کدام صفحه.
  2. استخراج. متن و فرمول‌ها از هر صفحه بیرون کشیده می‌شوند و هر صفحه در هر حال به تصویر هم تبدیل می‌شود،‏ چون بازبین به آن نیاز خواهد داشت.
  3. تفکیک. صفحه‌ها به سؤال‌ها شکسته می‌شوند و هر چه به هر سؤال تعلق دارد به آن وصل می‌شود: شکل‌ها،‏ متن‌های مشترک درک مطلب،‏ گزینه‌ها.
  4. تبدیل. هر سؤال با مجموعه‌ای کوچک و توافق‌شده از بسته‌ها و ماکروها به LaTeX تبدیل می‌شود.
  5. اعتبارسنجی. هر سؤال جداگانه کامپایل و نتیجه‌اش ثبت می‌شود.
  6. برچسب‌گذاری. از یک مدل زبانی موضوع،‏ مهارت و دشواری خواسته می‌شود،‏ محدود به واژگانی از پیش تعیین‌شده.
  7. بازبینی و انتشار. یک انسان سؤال را با منبع مقایسه می‌کند و تأیید،‏ ویرایش یا رد می‌کند.

هر مرحله خروجی و وضعیتش را با کلید سؤال و نسخهٔ مرحله در پایگاه‌داده می‌نویسد. خطا در برچسب‌گذاری استخراج را از نو اجرا نمی‌کند و وقتی منطق یک مرحله بهتر شد،‏ می‌شود فقط همان مرحله را روی سؤال‌هایی که تحت تأثیرش هستند دوباره اجرا کرد. با چند هزار سؤال،‏ این ویژگی از سرعت هر مرحلهٔ تکی مهم‌تر است.

خطاها از استخراج شروع می‌شوند

PDFها دو نوع‌اند. فایل‌هایی که مستقیم از نرم‌افزار بیرون آمده‌اند لایهٔ متنی دارند و کتابخانه‌هایی مثل PyMuPDF متن را تحویل می‌دهند. مشکل فرمول‌هاست: کسرها به‌صورت خط‌های جدا درمی‌آیند،‏ توان‌ها روی خط اصلی می‌افتند و فونت‌های نمادها به نویسه‌های اشتباه نگاشت می‌شوند. فایل‌های اسکن‌شده اصلاً لایهٔ متنی ندارند و OCR لازم دارند؛‏ بهتر از همه مدلی که ریاضی را بشناسد و مستقیم LaTeX بدهد.

هیچ‌کدام از این دو مسیر آن‌قدر مطمئن نیست که بشود چشم‌بسته به آن اعتماد کرد. قاعده‌ای که از آن کوتاه نمی‌آیم این است که برش تصویر همان ناحیه از صفحهٔ اصلی،‏ کنار هر سؤال استخراج‌شده و تا انتهای خط پردازش همراهش بماند. به این ترتیب هر تصمیم بعدی،‏ چه از طرف اسکریپت و چه از طرف آدم،‏ با چیزی که واقعاً در آزمون چاپ شده بود قابل مقایسه است.

اعتبارسنجی با کامپایل

ارزان‌ترین بررسی مفید برای یک تکه LaTeX کامپایل کردنش است. سؤال را در یک سند حداقلی بپیچید،‏ کامپایلر را با محدودیت زمانی اجرا کنید و خط‌های خطا را از لاگ نگه دارید:

import subprocess
import tempfile
from pathlib import Path

TEMPLATE = r"""\documentclass{article}
\usepackage{amsmath,amssymb}
\begin{document}
%s
\end{document}
"""

def compile_item(body: str, timeout_s: int = 20) -> tuple[bool, str]:
    """Compile one item in isolation; return (ok, first error lines)."""
    with tempfile.TemporaryDirectory() as tmp:
        Path(tmp, "item.tex").write_text(TEMPLATE % body, encoding="utf-8")
        try:
            subprocess.run(
                ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", "-no-shell-escape", "item.tex"],
                cwd=tmp, capture_output=True, timeout=timeout_s, check=False,
            )
        except subprocess.TimeoutExpired:
            return False, "timeout"
        log_path = Path(tmp, "item.log")
        log = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
        errors = [line for line in log.splitlines() if line.startswith("!")]
        return Path(tmp, "item.pdf").exists() and not errors, "\n".join(errors[:5])

این روش آکولادهای ناجور،‏ ماکروهای ناشناخته،‏ جداکننده‌های ریاضیِ جاافتاده و بسته‌های خارج از مجموعهٔ توافق‌شده را پیدا می‌کند. اما معنا را نمی‌سنجد. یک علامت منفیِ جاافتاده،‏ توانی که به جملهٔ اشتباه چسبیده یا دو گزینه‌ای که جایشان عوض شده،‏ همه بی‌نقص کامپایل می‌شوند. کامپایل موفق فقط می‌گوید سؤال خراب نیست.

ورودی را غیرقابل‌اعتماد فرض کنید. -no-shell-escape جلوی اجرای فرمان از داخل سند را می‌گیرد،‏ اما LaTeX همچنان می‌تواند فایل‌های روی ماشین را بخواند؛‏ پس جای مرحلهٔ کامپایل یک کانتینر محدود و بدون دسترسی شبکه است،‏ با محدودیت زمانی تا ماکرویی که در حلقهٔ بی‌پایان افتاده نتواند یک worker را برای همیشه گرو بگیرد. اگر سؤال‌ها خط غیرلاتین دارند،‏ مثلاً فارسی،‏ XeLaTeX یا LuaLaTeX موتور مناسب‌تری است.

برچسب‌گذاری با مدل زبانی: اول schema

برچسب فقط وقتی به درد می‌خورد که از یک واژگان ثابت بیاید. اگر آزادانه از مدل بپرسید «موضوع این سؤال چیست؟»،‏ هر چند صد سؤال یک برچسب تازه از خودش درمی‌آورد،‏ و برچسبی که فقط یک بار وجود دارد برای جست‌وجو یا ساختن آزمون بی‌ارزش است. پس طبقه‌بندی موضوعی در پایگاه‌داده نگه داشته می‌شود و هم prompt و هم schema خروجی از روی آن ساخته می‌شوند.

OpenRouter یک API سازگار با OpenAI دارد،‏ پس کلاینت استاندارد openai در پایتون با یک آدرس پایهٔ متفاوت کار می‌کند. برای مدل‌هایی که پشتیبانی می‌کنند،‏ response_format با یک JSON schema خروجی را محدود می‌کند. مستندات خود OpenRouter صریحاً می‌گوید میزان پایبندی به schema به ارائه‌دهنده بستگی دارد،‏ پس خروجی یک بار دیگر در کد اعتبارسنجی می‌شود. JSON schema می‌تواند بگوید skill یکی از چند رشتهٔ مشخص است،‏ اما قاعدهٔ «مهارت باید زیرمجموعهٔ همان موضوع انتخاب‌شده باشد» را راحت‌تر می‌شود با Pydantic اعمال کرد:

import json
import os
from typing import Literal

from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError, model_validator

client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"])

TAXONOMY = {                      # loaded from the database in the real service
    "algebra": ["linear-equations", "quadratics", "inequalities"],
    "geometry": ["triangles", "circles", "coordinate-geometry"],
}

SYSTEM_PROMPT = (
    "You classify exam questions. Choose topic and skill only from this taxonomy:\n"
    + json.dumps(TAXONOMY, indent=2)
    + "\nDifficulty is your estimate for the target students: easy, medium or hard."
    " If no skill fits well, choose the closest one and set needs_review to true."
    " Keep the rationale to two sentences."
)

SCHEMA = {
    "type": "object",
    "properties": {
        "topic": {"type": "string", "enum": sorted(TAXONOMY)},
        "skill": {"type": "string", "enum": sorted({s for v in TAXONOMY.values() for s in v})},
        "difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
        "needs_review": {"type": "boolean"},
        "rationale": {"type": "string"},
    },
    "required": ["topic", "skill", "difficulty", "needs_review", "rationale"],
    "additionalProperties": False,
}

class ItemTags(BaseModel):
    topic: str
    skill: str
    difficulty: Literal["easy", "medium", "hard"]
    needs_review: bool
    rationale: str = Field(max_length=500)

    @model_validator(mode="after")
    def skill_belongs_to_topic(self):
        if self.skill not in TAXONOMY.get(self.topic, []):
            raise ValueError(f"skill '{self.skill}' is not under topic '{self.topic}'")
        return self

def tag_item(item_latex: str, model: str) -> ItemTags | None:
    messages = [{"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": item_latex}]
    for _ in range(2):                                   # one retry, showing the model its error
        resp = client.chat.completions.create(
            model=model,
            temperature=0,
            messages=messages,
            response_format={"type": "json_schema",
                             "json_schema": {"name": "item_tags", "strict": True, "schema": SCHEMA}},
        )
        raw = resp.choices[0].message.content or ""
        try:
            return ItemTags.model_validate_json(raw)
        except ValidationError as err:
            messages += [{"role": "assistant", "content": raw},
                         {"role": "user", "content": f"Invalid output: {err}. Answer again with valid JSON."}]
    return None                                          # goes to the review queue untagged

چند تصمیم در این طراحی عمدی است. مدل یک راه فرار صریح دارد،‏ یعنی needs_review،‏ به‌جای این‌که مجبور شود وانمود کند هر سؤالی در دسته‌ها جا می‌شود. اطمینانی که مدل دربارهٔ خودش گزارش می‌کند خوب کالیبره نیست،‏ پس این پرچم می‌تواند سؤالی را به صف بازبینی اضافه کند ولی هیچ‌وقت نمی‌تواند سؤالی را از آن بیرون ببرد. توضیح کوتاه (rationale) کنار برچسب‌ها به بازبین نشان داده می‌شود. دمای صفر تنوع را کم می‌کند ولی خروجی را قطعی نمی‌کند،‏ پس هر نتیجه همراه با شناسهٔ مدل،‏ نسخهٔ prompt و پاسخ خام ذخیره می‌شود. هر وقت مدل یا prompt عوض شد،‏ پیش از جابه‌جایی،‏ یک مجموعهٔ ثابت از سؤال‌های قبلاً بازبینی‌شده را دوباره اجرا و مقایسه کنید.

مدل‌ها کجا اشتباه می‌کنند

هیچ‌کدام از این‌ها عجیب نیست؛‏ همه از این ناشی می‌شوند که مدل چه چیزی را می‌بیند و چه چیزی را نمی‌بیند.

  • دشواری. «سخت» از نگاه مدل همان «سخت» برای دانش‌آموزان شما نیست. دشواری واقعی از دادهٔ پاسخ‌ها می‌آید،‏ همان پارامتر b در یادداشت آزمون تطبیقی. برآورد مدل زبانی برای چیدن آزمون‌های اولیه یک پیش‌فرض معقول و برای مرتب کردن کار بازبین کلید خوبی است،‏ و به‌محض این‌که پاسخ واقعی جمع شد باید جایش را به کالیبراسیون بدهد.
  • دسته‌های همسایه. نامعادله‌ای که با معادلهٔ درجهٔ دو حل می‌شود،‏ زیر هر دو مهارت می‌تواند بنشیند. اگر دو معلم دربارهٔ سؤالی با هم اختلاف داشته باشند،‏ مدل هم دربارهٔ آن جواب ثابتی نخواهد داد. اصلاح طبقه‌بندی،‏ با تعریف‌ها و مثال‌های روشن،‏ بیشتر از بازنویسی prompt کمک می‌کند.
  • زمینهٔ گم‌شده. سؤالی که به یک شکل،‏ یک متن مشترک یا «پاسخ شما به سؤال ۳» ارجاع می‌دهد،‏ به‌تنهایی نه قابل برچسب‌گذاری است و نه حتی قابل استفاده. این مشکلِ مرحلهٔ تفکیک است که در مرحلهٔ برچسب‌گذاری خودش را نشان می‌دهد.
  • تبدیل باورپذیر اما غلط. مدل‌ها در تولید LaTeXی که درست به نظر می‌رسد مهارت دارند. وقتی OCR یا مدل بی‌صدا نمادگذاری را «اصلاح» می‌کند،‏ نتیجه کامپایل می‌شود و خوب هم خوانده می‌شود،‏ و فقط مقایسه با منبع آن را لو می‌دهد.

کنسول بازبینی

به همین دلیل مرحلهٔ آخر یک انسان است و کنسول بازبینی ارزش مهندسی جدی را دارد. یک صفحهٔ بازبینی خوب به چند چیز خلاصه می‌شود: برش صفحهٔ اصلی در یک طرف،‏ LaTeX رندرشده در طرف دیگر،‏ ویرایشگر LaTeX همراه با وضعیت کامپایل،‏ و برچسب‌های پیشنهادی با توضیح مدل. کارهای ممکن هم تأیید،‏ ویرایش یا رد است.

پشت این صفحه چند قاعده بانک سؤال را قابل اعتماد نگه می‌دارد:

  • هر سؤال وضعیت صریحی دارد،‏ مثل استخراج‌شده،‏ اعتبارسنجی‌شده،‏ برچسب‌خورده،‏ در حال بازبینی،‏ تأییدشده و منتشرشده،‏ و API فقط گذارهای مجاز را می‌پذیرد.
  • هر ویرایش دوباره اعتبارسنجی را راه می‌اندازد و تأیید فقط با کامپایل موفق ممکن است.
  • انتشار یک نسخهٔ تازه از سؤال را با نام بازبین ثبت می‌کند تا بانک سابقهٔ تغییرات داشته باشد.
  • صف بازبینی ترتیب دارد: اول سؤال‌هایی که کامپایل نشده‌اند و آن‌هایی که needs_review خورده‌اند،‏ بعد نمونه‌ای تصادفی از سؤال‌هایی که مدل درباره‌شان مطمئن بوده. نمونه‌گیری از همین سؤال‌های «مطمئن» راه پیدا کردن خطاهای نظام‌مند است؛‏ همان خطاهایی که مدل با اطمینان کامل مرتکب می‌شود.
  • کارهای کند،‏ مثل کامپایل و فراخوانی مدل زبانی،‏ در workerهای پس‌زمینه اجرا می‌شوند نه داخل درخواست،‏ تا کنسول وقتی دستهٔ تازه‌ای در حال پردازش است هم سریع بماند.

در این خط پردازش،‏ مدل زبانی کار بازبین را از تایپ کردن فراداده به بررسی آن تبدیل می‌کند که سریع‌تر و کم‌ملال‌تر است،‏ به شرطی که بررسی واقعی باشد. اگر انبوهی سند دارید که باید با کمک یک مدل زبانی به دادهٔ ساخت‌یافته تبدیل شود،‏ این از کارهایی است که انجام می‌دهم. یادداشت بعدی شخصی است: این‌که مسیر از پژوهش تانسوری تا مهندسی بک‌اند چه چیزهایی به من یاد داد.

این یادداشت به انگلیسی هم منتشر شده است: نسخهٔ انگلیسی.

بازگشت به همهٔ یادداشت‌ها