Yapay zekâ destekli içerik üretiminde kalite güvencesi: kapılar, bağımsız doğrulama, karantina

Model çıktısını yayına almadan önce yapısal kapılardan geçirmek, sayısal cevapları bağımsız çözümle doğrulamak ve şüpheliyi karantinaya almak.

Yazar: TS SystemLabs mühendislik ekibi · Yayın: · 8 dk okuma

Büyük dil modelleri, bir konudan birkaç dakikada ders metni ve sınav sorusu üretebiliyor. Bu hız, eğitim içeriği hazırlayan bir ekip için çok cazip; ama üretilen her metin yayına uygun değil. Model, akıcı bir dille yanlış bir cevabı doğru diye işaretleyebilir, dört şıktan ikisini aynı anlama gelecek biçimde yazabilir ya da doğru şıkkı her seferinde en uzun ve en ayrıntılı seçenek yaparak soruyu farkında olmadan kolaylaştırabilir. Okur, bu hataları yazının akıcılığı yüzünden çoğu zaman fark etmez.

Bu yazıda, ScholaMundus eğitim platformu için kurduğumuz içerik hattında model çıktısını yayına almadan önce nasıl denetlediğimizi anlatıyoruz. Hat bir konudan sekiz derslik bir kurs ve on soruluk, her sorusu gerekçeli bir sınav üretiyor. Yaklaşımımız basit bir ilkeye dayanıyor: modelin çıktısı bir taslaktır; yayına ancak bir dizi otomatik kapıdan geçtikten ve şüpheli görülenler bir insan tarafından incelendikten sonra çıkar. Örnek kodlar Python ile yazıldı ve yalnız standart kütüphane kullanıyor.

İlke: model çıktısı bir taslaktır

Kalite güvencesinin ilk adımı teknik değil, zihinseldir. Model çıktısını "bitmiş içerik" olarak değil, denetlenmesi gereken bir taslak olarak görmek, bütün hattın tasarımını değiştirir. Üretim adımı ile yayın adımı arasına bilerek bir boşluk koyarsınız; bu boşlukta içerik, ölçülebilir kurallardan geçer. Kurallar yazılı ve otomatik olduğu için her taslağa aynı biçimde uygulanır; yorgunluk, acele ya da "bu seferlik geçsin" kararı devreye girmez.

İkinci önemli karar, modelden serbest metin yerine yapılandırılmış veri istemektir. Ders başlığı, ders metni, soru, şıklar, doğru şıkkın sırası ve gerekçe ayrı alanlar olarak geldiğinde her alanı ayrı ayrı denetleyebilirsiniz. Aşağıdaki örneklerde modelden JSON istendiğini ve gelen verinin önce bir veri sınıfına dönüştürüldüğünü varsayıyoruz. Dönüştürme başarısız olursa taslak daha ilk adımda reddedilir.

from __future__ import annotations

import ast
import json
import operator
import shutil
from collections import Counter
from dataclasses import dataclass
from fractions import Fraction
from pathlib import Path


@dataclass
class Soru:
    kimlik: str
    metin: str
    siklar: list[str]
    dogru: int                    # 0 tabanlı şık sırası
    gerekce: str
    ifade: str | None = None      # sayısal sorularda bağımsız çözüm için, ör. "3/4 + 1/6"

Yapısal doğrulama: önce biçim

İlk kapı içeriğin anlamına bakmaz, biçimine bakar. Soru metni makul bir uzunlukta mı? Şık sayısı beklendiği gibi dört ya da beş mi? Aynı şık iki kez yazılmış mı? Doğru şıkkın sırası gerçekten var olan bir şıkkı mı gösteriyor? Gerekçe, bir cümleyle geçiştirilmiş mi, yoksa öğrenciye neden doğru olduğunu anlatacak kadar uzun mu? Bu soruların her biri birkaç satırlık bir kontrol, ama birlikte ciddi bir hata sınıfını yakalıyor.

Aynı mantık ders metinlerine de uygulanıyor: çok kısa bir ders, büyük ihtimalle modelin konuyu yüzeysel geçtiği anlamına gelir. Biz kısa dersleri ayrı bir kuralla işaretliyoruz. Eşik değerleri içerik türüne göre ayarlamak gerekir; aşağıdaki sayılar örnek amaçlıdır.

Yapı kapısının yanına bir de "en uzun şık" kapısı koyuyoruz. Soru yazarken, insanlar da modeller de doğru cevabı daha dikkatli ve daha ayrıntılı yazma eğilimindedir. Sonuçta doğru şık, diğerlerinden belirgin biçimde uzun olur ve konuyu bilmeyen biri bile tahminle doğruyu bulabilir. Kapı, doğru şık hem en uzun hem de ikinci en uzun şıktan belirli bir oranda uzunsa taslağı işaretler.

def yapi_kapisi(s: Soru) -> list[str]:
    sorun = []
    if not 20 <= len(s.metin) <= 600:
        sorun.append("soru metni uzunluğu sınır dışı")
    if len(s.siklar) not in (4, 5):
        sorun.append("şık sayısı 4 ya da 5 olmalı")
    if len({x.strip().lower() for x in s.siklar}) != len(s.siklar):
        sorun.append("aynı şık iki kez yazılmış")
    if not 0 <= s.dogru < len(s.siklar):
        sorun.append("doğru şık sırası geçersiz")
    if len(s.gerekce) < 40:
        sorun.append("gerekçe çok kısa")
    return sorun


def uzun_sik_kapisi(s: Soru, oran: float = 1.3) -> list[str]:
    uzunluklar = sorted((len(x) for x in s.siklar), reverse=True)
    dogru_uzunluk = len(s.siklar[s.dogru])
    if dogru_uzunluk == uzunluklar[0] and dogru_uzunluk >= oran * uzunluklar[1]:
        return ["doğru şık belirgin biçimde en uzun şık"]
    return []

Şık dengesi: doğru cevabın harf dağılımı

Bazı sorunlar tek bir soruda değil, bir sınavın bütününde görünür. Modeller doğru cevabı belli bir harfte toplama eğilimi gösterebilir; on soruluk bir sınavda doğru cevapların yarısının aynı harfte olması, dikkatli bir öğrencinin fark edeceği bir örüntüdür. Bu yüzden sınavın tamamına bakan bir dağılım kapısı ekliyoruz: herhangi bir harf, doğru cevapların belirli bir oranından fazlasını taşıyorsa sınav işaretlenir.

Bu sorunun çözümü çoğu zaman yeniden üretim değildir. Soruların içeriği doğruysa şıkların sırasını karıştırmak ve doğru şık sırasını buna göre güncellemek yeterlidir. Ancak karıştırmadan sonra da dağılım kapısını yeniden çalıştırmak gerekir; rastgele karıştırma küçük sınavlarda da dengesiz sonuç verebilir.

Bağımsız çözüm: sayısal cevabı ikinci yoldan doğrulamak

En tehlikeli hata, akıcı bir gerekçeyle desteklenmiş yanlış cevaptır. Sözel sorularda bunu otomatik olarak yakalamak zordur; sayısal sorularda ise mümkündür. Yöntem şu: modelden soruyla birlikte, cevabı veren matematiksel ifadeyi de ayrı bir alanda istiyoruz. Sonra bu ifadeyi modelden bağımsız, deterministik bir kodla hesaplıyor ve sonucu işaretlenen şıkla karşılaştırıyoruz.

Burada iki ayrıntı önemli. Birincisi, ifadeyi eval ile çalıştırmak güvenli değildir; model çıktısı ne olursa olsun kod olarak çalıştırılmamalıdır. Bu yüzden ifadeyi Python'un ast modülüyle ayrıştırıyor ve yalnız dört işlem ile sayılara izin veren küçük bir değerlendirici kullanıyoruz. İkincisi, kayan noktalı sayılar 0.1 + 0.2 gibi işlemlerde küçük yuvarlama farkları üretir. Kesirli cevapları fractions.Fraction ile tam sayı oranları olarak hesaplamak, karşılaştırmayı kesin hâle getirir. Ondalık gösterimle çalışan alanlarda decimal.Decimal da benzer bir güvence sağlar.

_ISLEMLER = {ast.Add: operator.add, ast.Sub: operator.sub,
             ast.Mult: operator.mul, ast.Div: operator.truediv}


def _hesapla(dugum: ast.AST) -> Fraction:
    if isinstance(dugum, ast.Expression):
        return _hesapla(dugum.body)
    if isinstance(dugum, ast.Constant) and isinstance(dugum.value, (int, float)):
        return Fraction(str(dugum.value))
    if isinstance(dugum, ast.BinOp) and type(dugum.op) in _ISLEMLER:
        return _ISLEMLER[type(dugum.op)](_hesapla(dugum.left), _hesapla(dugum.right))
    if isinstance(dugum, ast.UnaryOp) and isinstance(dugum.op, ast.USub):
        return -_hesapla(dugum.operand)
    raise ValueError("desteklenmeyen ifade")


def bagimsiz_cozum_kapisi(s: Soru) -> list[str]:
    if s.ifade is None:
        return []
    try:
        beklenen = _hesapla(ast.parse(s.ifade, mode="eval"))
        verilen = Fraction(s.siklar[s.dogru].replace(",", ".").strip())
    except (ValueError, ZeroDivisionError, SyntaxError):
        return ["sayısal cevap bağımsız olarak doğrulanamadı"]
    if beklenen != verilen:
        return [f"bağımsız çözüm {beklenen}, işaretlenen {verilen}"]
    dogru_sayisi = 0
    for sik in s.siklar:
        try:
            dogru_sayisi += Fraction(sik.replace(",", ".").strip()) == beklenen
        except ValueError:
            pass
    return [] if dogru_sayisi == 1 else ["birden çok şık doğru cevaba eşit"]

Kapı yalnız işaretlenen şıkkın doğru olduğunu değil, başka bir şıkkın da aynı değere eşit olmadığını kontrol ediyor. "3/4" ile "0,75" aynı sayıdır; iki şık bu biçimde yazılmışsa soru iki doğru cevaplı hâle gelir. Bu, gözle incelemede kolayca kaçabilecek bir hatadır.

Kapıları bir zincire bağlamak

Kapıları bir listede tutmak, yeni bir kural eklemeyi tek satırlık bir işe dönüştürür. Denetim fonksiyonu kapıları sırayla çalıştırır ve bulunan sorunları toplar. Yapı kapısı başarısız olursa diğer kapıları çalıştırmanın anlamı yoktur; şık sırası geçersizse uzunluk karşılaştırması da hata verir. Bu yüzden zincir, yapısal bir sorun bulunduğunda erken durur.

KAPILAR = [yapi_kapisi, uzun_sik_kapisi, bagimsiz_cozum_kapisi]


def denetle(s: Soru) -> list[str]:
    sorunlar = []
    for kapi in KAPILAR:
        sorunlar += kapi(s)
        if kapi is yapi_kapisi and sorunlar:
            break                     # yapı bozuksa diğer kapılar anlamsız
    return sorunlar


def dagilim_kapisi(sorular: list[Soru], tavan: float = 0.4) -> list[str]:
    sayac = Counter("ABCDE"[s.dogru] for s in sorular)
    return [f"doğru cevapların {sayi}/{len(sorular)} tanesi {harf} şıkkında"
            for harf, sayi in sayac.items() if sayi / len(sorular) > tavan]

Karantina ve insan incelemesi

Kapılardan geçemeyen bir taslak silinmez; karantinaya alınır. Silmek, aynı hatanın neden tekrar ettiğini anlamayı imkânsız hâle getirir. Karantina klasörü, hem reddedilen taslağı hem de reddedilme gerekçelerini yan yana tutar. Bir insan bu klasörü düzenli olarak inceler ve her taslak için üç karardan birini verir: düzeltip yayına almak, yeniden ürettirmek ya da tamamen bırakmak.

def karantinaya_al(dosya: Path, sorunlar: list[str], klasor: Path) -> Path:
    klasor.mkdir(parents=True, exist_ok=True)
    hedef = klasor / dosya.name
    shutil.move(str(dosya), hedef)
    hedef.with_suffix(".sorunlar.json").write_text(
        json.dumps(sorunlar, ensure_ascii=False, indent=2), encoding="utf-8")
    return hedef

Karantinanın bir yan faydası daha var: hangi kapının ne sıklıkla taslak reddettiğini gösterir. Bir kapı sürekli aynı türde hata yakalıyorsa, sorun büyük ihtimalle üretim talimatındadır ve düzeltilmesi gereken yer orasıdır. Kapılar böylece yalnız bir filtre değil, üretim sürecinin geri bildirim kanalı olur.

Aşağıdaki küçük örnek, aynı sorunun doğru ve yanlış işaretlenmiş iki sürümünün kapılardan nasıl geçtiğini gösteriyor. İkinci sürümde gerekçe doğru sonucu anlatıyor, ama işaretlenen şık farklı; bağımsız çözüm kapısı bu çelişkiyi yakalıyor.

s = Soru("mat-001", "3/4 ile 1/6 sayılarının toplamı kaçtır?",
         ["11/12", "4/10", "5/6", "1"], 0,
         "Paydalar 12'de eşitlenir: 9/12 + 2/12 = 11/12 olur.", "3/4 + 1/6")
print(denetle(s))
s2 = Soru("mat-002", "3/4 ile 1/6 sayılarının toplamı kaçtır?",
          ["11/12", "4/10", "5/6", "1"], 2,
          "Paydalar 12'de eşitlenir: 9/12 + 2/12 = 11/12 olur.", "3/4 + 1/6")
print(denetle(s2))
print(dagilim_kapisi([s, s, s2]))
import tempfile
d = Path(tempfile.mkdtemp()); f = d / "mat-002.json"; f.write_text("{}", encoding="utf-8")
print(karantinaya_al(f, denetle(s2), d / "karantina"))
print(Fraction("0.75") == Fraction(3, 4))

Kaynak ve telif disiplini

Sınav hazırlığı içeriğinde en kolay yol, geçmiş yılların çıkmış sorularını kullanmaktır. Bu soruların telif hakları ise sınavı düzenleyen kuruma aittir. Bu yüzden hattımızda çıkmış soru çekilmez; model, konu ve kazanım tanımından yola çıkarak özgün soru üretir. Aynı ilke ders metinleri için de geçerlidir: belirli bir kitabın ya da sitenin metnini yeniden üretmesi istenmez.

Özgünlüğü tam olarak otomatik doğrulamak mümkün değildir, ama kaba bir kontrol mümkündür. Yeni soruyu, elinizdeki soru bankasıyla metin benzerliği açısından karşılaştırmak, birbirinin neredeyse aynısı olan soruları yakalar. Bu, hem tekrar eden içeriği hem de bankaya bir şekilde girmiş dış kaynaklı metinleri fark etmenize yardım eder.

Yayın sonrası: geri bildirim ve şeffaflık

Kapılar ne kadar iyi olursa olsun, bazı hatalar yayına çıkar. Bunun için iki şey gerekiyor. Birincisi, öğrencinin bir soruyu tek tıkla "hatalı olabilir" diye işaretleyebilmesi ve bu işaretin doğrudan karantina kuyruğuna düşmesi. İkincisi, işaretlenen her hatanın, mümkünse, yeni bir kapıya ya da var olan bir kapının eşiğine dönüşmesi. Böylece aynı türde hata ikinci kez yayına çıkmaz.

Son olarak şeffaflık: içeriğin yapay zekâ desteğiyle üretildiğini ve insan denetiminden geçtiğini okura açıkça söylemek, hem dürüst hem de sağlıklı bir beklenti oluşturur. Okur, bir hatayla karşılaştığında bunun bir süreçten geçen taslak olduğunu bilir ve bildirmekte tereddüt etmez.

Sonuç

Yapay zekâ destekli içerik üretiminde kalite, modelin kendisinden çok etrafına kurulan süreçten gelir. Yapılandırılmış çıktı, biçim kapıları, şık dengesi, sayısal cevapların bağımsız bir yoldan doğrulanması, silmek yerine karantina ve insan incelemesi; bunların her biri basit ama birlikte güçlü bir güvence oluşturur. Bu kapıların hiçbiri mükemmel değildir; amaç, hataların büyük kısmını yayından önce yakalamak ve kalanları hızla düzeltilebilir kılmaktır. Kendi içerik hattınız için benzer bir denetim düzeni kurmak isterseniz bize yazabilirsiniz.

Benzer bir sistem mi kuruyorsunuz?

İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.

Bize yazın