Luma public docs
English

Kütüphane

IO & Multimodal

Luma'nın dosya erişimi bilinçli olarak dardır: proje kökünün dışına çıkılamaz, yollar @ ön ekiyle kök-göreli yazılır ve tüm IO io fn sınıfındadır — yani schema doğrulaması hiçbir zaman diske dokunamaz. Bunun üzerine görsel ve PDF içeriğini modele taşıyan bir marker sistemi kuruludur.

Proje kökü ve @ yolları

proje/                     ← io kökü (en yakın package.json burada)
├── package.json
├── luma/
│   └── fatura.luma        çalıştırılan program
├── 0213/
│   ├── page-1.png
│   └── page-2.png
└── docs/
    └── belge.md

  "@0213/page-1.png"  →  proje/0213/page-1.png
  "@/docs/belge.md"   →  proje/docs/belge.md
  "docs/belge.md"     →  proje/docs/belge.md   (@ olmadan da kök-göreli)
  "../gizli"          →  reddedilir
KuralDetay
Kök nasıl bulunurÇalıştırılan .luma dosyasının klasöründen yukarı doğru gidilir; ilk package.json içeren dizin kök olur.
@ ön eki@yol ve @/yol aynı şeydir: kök-göreli. Ön ek olmadan da yollar köke göre çözülür.
SınırKökün dışına çıkan her yol reddedilir: io path cannot escape the project directory: ../x
Boş yolio path expects a non-empty string.
Dönen yollar da kök-görelidir

io.listFiles @ ön ekli yollar döndürür (doğrudan diğer io fonksiyonlarına verilebilir). io.readFile/readFiles sonuçlarındaki path alanı ve io.writeText'in dönüş değeri ise ön eksiz kök-göreli yollardır.

Dosya okuma ve yazma

luma
import io from "std/io"

agent FileWorker {
  fn run() -> Result {
    exists  = io.exists("@docs/belge.md")
    text    = io.readText("@docs/belge.md")
    mime    = io.mimeType("@0213/page-1.png")     // "image/png"
    file    = io.readFile("@0213/page-1.png")
    dataUrl = io.toDataUrl(file)
    written = io.writeText("@cikti/ozet.md", text)
    return { path: written }
  }
}

io.readFile sonucunun şekli

json
{
  "path":      "0213/page-1.png",
  "mime_type": "image/png",
  "base64":    "iVBORw0KGgoAAAANSUhEUg…",
  "size":      184213
}

MIME eşlemesi

UzantıMIME
.pngimage/png
.jpg · .jpegimage/jpeg
.webpimage/webp
.gifimage/gif
.svgimage/svg+xml
.pdfapplication/pdf
.mdtext/markdown
.txttext/plain
.jsonapplication/json
diğerapplication/octet-stream

io.writeText hedefin ara klasörlerini otomatik oluşturur — çıktı klasörünü önceden hazırlamana gerek yoktur.

Dosya listeleme

luma
pages = io.listFiles("@0213", "*.png", false)
// ["@0213/page-1.png", "@0213/page-2.png", "@0213/page-10.png"]
ParametreKural
directoryKlasör olmalıdır; dosya verilirse io listFiles expects a directory.
patternYalnızca dosya adı glob'u: * (herhangi) ve ? (tek karakter). / veya \ içeremez. Büyük/küçük harf duyarsızdır.
recursivetrue ise alt klasörlere iner.
Sıralama doğaldır

Sonuçlar doğal sıralamayla döner: page-2.png, page-10.png'den önce gelir. Sayfa sırasının önemli olduğu belge pipeline'larında bu, dosya adlarını sıfırla doldurmak zorunda kalmadan doğru sırayı verir.

✓ Doğru
pages = io.listFiles("@0213", "*.png", false)
for page in pages {
  ...
}
✗ Yanlış
// The pattern cannot contain a path:
pages = io.listFiles("@", "0213/*.png", false)
// io listFiles pattern expects
// only a file name glob.

Multimodal marker'lar

Görsel ve dosya içeriği prompt'a marker olarak gömülür. Model profilinde multimodal: true olduğunda bu marker'lar content part'larına çevrilir.

{{image_url:<url veya data URL>}}
{{file_url:<url veya data URL>}}
luma
// 1) A single file, by hand
file    = io.readFile("@0157.pdf")
dataUrl = io.toDataUrl(file)
text    = ask "Bu faturayı Markdown'a çevir. Belge: {{file_url:{dataUrl}}}" -> Transcript

// 2) Many files, automatically
files   = io.readFiles(pages)
markers = io.toMultimodalMarkers(files)
text    = ask "Tüm sayfaları sırayla Markdown'a çevir: {markers}" -> Transcript

Bu iki biçim birbirinin yerine geçmez ve ikisini ayıran kural bellekle ilgilidir. io.readFiles kendisine verdiğin her yolu okur, her birini base64'e çevirir ve listenin tamamını döndürür — yani dosyaların hepsi aynı anda bellekte durur, diskteki boyutlarının kabaca 4/3'ü kadar. Dosyalar küçükse ve zaten hepsini tek prompt'ta istiyorsan liste ver; sayfa, tarama veya PDF söz konusuysa her seferinde [tek] ver. Aşağıdaki uçtan uca pipeline'ın bir kez io.readFiles(pages) yerine döngü içinde io.readFiles([page]) çağırmasının nedeni budur.

Adet tavanı var, byte tavanı yok

io.readFiles --max-rows ile sınırlanır — bir query sonucunu yöneten tavanın aynısı, çünkü bu tavan io'ya özgü bir tuhaflık değil, satır üreten tüm yollar için geneldir. Varsayılan 200000 yolun üzerinde çağrı, hiçbir şey okumadan önce adıyla reddeder:

Error: Result set exceeded the row limit (200000). Process in batches with col.chunk, or raise --max-rows.

Bu sınırın neyi saydığına dikkat et: byte değil, yol. 50 MB'lık iki yüz tarama tavanın çok altındadır ve yine de heap'i tüketir; çünkü bu yolda, tek bir std/http gövdesini sınırlayan --max-response-bytes'ın karşılığı olan hiçbir şey yoktur. Mesajın adlandırdığı çıkış yolu gerçektir — col.chunk, yol listesini tek tek okuyacağın partilere böler — ve bu, boyutu aşan bir query sonucu için de aynı çıkış yoludur. Bkz. Bayraklar ve tavanlar.

io.toMultimodalMarkers her dosyanın MIME tipine bakarak doğru marker'ı seçer ve satır satır birleştirir:

MIMEÜretilen marker
image/*{{image_url:data:image/png;base64,…}}
application/pdf{{file_url:data:application/pdf;base64,…}}
diğer{{file_url:…}}
multimodal: false ise marker düz metindir

Model profilinde multimodal: true yoksa marker'lar hiç işlenmez ve prompt'a olduğu gibi — base64 içeriğiyle birlikte — düz metin olarak gider. Bu hem token'ı boşa harcar hem de modelin görseli görmemesine yol açar. Görsel işleyen her model profiline bu bayrağı ekle.

Provider content eşlemesi

Adapter{{image_url:…}}{{file_url:…}}
OpenAI-compatible · Azure{ type: "image_url", image_url: { url } }{ type: "file_url", file_url: { url } }
Anthropic{ type: "image", source: { type: "url", url } }data URL → { type: "document", source: { type: "base64", media_type, data } }
düz URL → source: { type: "url", url }
Gemini{ fileData: { mimeType: "image/*", fileUri: url } }data URL → { inlineData: { mimeType, data } }
düz URL → { fileData: { … } }
Base64 içerik her adapter'da doğru alana gider

Üç adapter de data URL'i gömülü içerik alanına eşler: OpenAI-compatible image_url/file_url, Anthropic source.type: "base64", Gemini inlineData. Barındırılan bir URL verdiğinde ise Gemini fileData.fileUri, Anthropic source.type: "url" kullanır. Yani io.toMultimodalMarkers çıktısı provider'dan bağımsız çalışır.

Uçtan uca: çok sayfalı fatura çıkarımı

Aşağıdaki pipeline, bir klasördeki PNG sayfalarını okuyup Markdown'a çevirir, sonra bu Markdown'dan tipli fatura verisi çıkarır ve iki dosya yazar. Depodaki luma/064_invoice_multi_desc_prompt.luma örneğinin özüdür.

  @0213/*.png  (doğal sırayla)
       ↓  her sayfa için ayrı multimodal ask
  Markdown parçaları  →  birleştir  →  @0213/pre.md
       ↓  schema.prompt(InvoiceExtract) + Markdown
  InvoiceExtract  (typed, doğrulanmış)
       ↓
  @0213/ext.json
  1. Girdi ve modelleri tanımla. Görsel için multimodal, metin çıkarımı için geniş bağlamlı ayrı bir model kullanmak hem kaliteyi hem maliyeti iyileştirir.
    luma
    input folder: string
    
    import io     from "std/io"
    import json   from "std/json"
    import schema from "std/schema"
    import num    from "std/numeric"
    
    model Vision {
      provider: "openai-compatible"
      base_url: "https://gateway.example/v1"
      model: "vision-model"
      api_key: getEnv("api_key")
      max_tokens: 6000
      multimodal: true
    }
    
    model TextExtractor {
      provider: "openai-compatible"
      base_url: "https://gateway.example/v1"
      model: "text-model"
      api_key: getEnv("api_key")
      max_tokens: 24000
    }
  2. Çıktı schema'sını alan açıklamalarıyla birlikte yaz. @desc metinleri JSON Schema'ya girer ve model için extraction talimatı işlevi görür.
    luma
    schema InvoiceItem {
      line_no: int | null   @desc("Faturadaki kalem sıra numarası")
      description: string | null @desc("Mal veya hizmet açıklaması")
      quantity: float | null @desc("Satılan miktar")
      unit_price: float | null @desc("İndirim öncesi birim fiyat")
      total: float | null @desc("Kalemin indirim sonrası KDV hariç toplamı")
    
      where quantity == null || unit_price == null || total == null || num.approximatelyEqual(quantity * unit_price, total, 0.01)
        @feedback("quantity * unit_price toplamına uymuyor")
    }
    
    schema MetaEntry {
      key: string
      value: string
    }
    
    schema InvoiceExtract
      @desc("Fatura Markdown'ından çıkarılan yapılandırılmış veri")
      @prompt("Aşağıdaki fatura Markdown metninden InvoiceExtract schema'sına uygun JSON üret. Alan açıklamalarını extraction talimatı olarak kullan. Her tabloda görünen her kalem için items içinde ayrı bir nesne üret; satır atlama veya özetleme. Metinde bulunmayan değerleri uydurma; nullable alanlarda null, array alanlarda [] kullan. Schema alanlarına sığmayan hiçbir bilgiyi kaybetme; bunları meta alanında anahtar-değer olarak koru.") {
      invoice_number: string | null @desc("Fatura numarası")
      invoice_date: string | null @desc("Fatura tarihi, YYYY-MM-dd HH:mm")
      items: InvoiceItem[] @desc("Faturadaki tüm kalemler")
      notes: string[]      @desc("Dipnotlar ve banka bilgileri")
      meta: MetaEntry[]   @desc("Schema alanlarına girmeyen tüm bilgiler")
    }
  3. Sayfaları tek tek transkribe et. Her sayfayı ayrı bir ask ile işlemek, tek istekte tüm sayfaları göndermekten belirgin şekilde daha güvenilirdir: token limiti aşılmaz ve model sayfaları karıştırmaz.
    luma
    agent InvoiceReader {
      model: Vision
      retry: 2
    
      fn transcribe() -> InvoiceTranscript {
        root     = "@" + folder
        pages    = io.listFiles(root, "*.png", false)
        markdown = ""
    
        for page in pages {
          files   = io.readFiles([page])
          markers = io.toMultimodalMarkers(files)
          part    = ask "Bu fatura görselini eksiksiz Markdown'a çevir. Tabloyu Markdown tablosu olarak koru; şirket, müşteri, kalemler, KDV, toplamlar ve dipnotları aktar. Yalnızca markdown alanını doldur. Görsel: {markers}" -> InvoiceTranscript
          markdown = markdown + part.markdown + "\n\n"
        }
    
        written = io.writeText(root + "/pre.md", markdown)
        return { markdown: markdown }
      }
    }
  4. Markdown'dan tipli veriyi çıkar. Talimat schema'nın kendisinden okunur.
    luma
    agent InvoiceExtractor {
      model: TextExtractor
      retry: 2
    
      fn extract(markdown: string) -> InvoiceExtract {
        instruction = schema.prompt(InvoiceExtract)
        result  = ask "{instruction}\n\nFATURA MARKDOWN:\n\n{markdown}" -> InvoiceExtract
        root    = "@" + folder
        written = io.writeText(root + "/ext.json", json.pretty(result))
        return result
      }
    }
  5. Pipeline'ı bağla ve çalıştır.
    luma
    reader     = InvoiceReader()
    extractor  = InvoiceExtractor()
    transcript = reader.transcribe()
    result     = extractor.extract(transcript.markdown)
    bun src/index.ts luma/fatura.luma \
      --folder 0213 \
      --all \
      --trace-file /tmp/fatura-trace.json

Neden bu şekilde?

KararGerekçe
Sayfa başına ayrı askToken limitini aşmaz, hata izole kalır ve tek sayfa başarısız olduğunda retry yalnızca o sayfayı yeniden dener.
Döngü içinde io.readFiles([page])Aynı anda tek bir sayfa base64'e çevrilir ve bellekte tutulur. io.readFiles(pages) tüm klasörü baştan kodlar ve method dönene kadar hepsini bellekte tutardı — uzun bir tarama için bellek tavanı budur ve bu yolda byte'ı sınırlayan hiçbir şey yoktur.
Ara pre.md dosyasıTranskripsiyon ile çıkarımı ayırır; çıkarım prompt'unu değiştirdiğinde pahalı görsel adımını tekrarlamana gerek kalmaz.
meta: MetaEntry[]Schema'ya sığmayan bilgiyi kaybetmeden taşır ve map<any>'nin strict gateway riskinden kaçınır.
Nullable alanlarModele "bulamadım" demesi için açık bir yol verir; uydurma değerleri azaltır.
Kalem düzeyinde whereAritmetik tutarsızlığı yakalar ve hata mesajını modele geri besler.
İki ayrı model profiliGörsel modeli pahalıdır ve dar bağlamlıdır; çıkarım modeli ucuzdur ve geniş bağlam ister.
Çıkarımı doğrulamanın pratik yolu

Üretilen ext.json'u gözle taramak yerine trace'teki doğrulama olaylarına bak — hangi kuralların kaç kez çalıştığını ve nelerin başarısız olduğunu görürsün:

jq -r '.events[] | select(.type=="validation" and .ok==false)
        | "\(.name) [\(.metadata.rule // "-")] \(.error // "-")"' /tmp/fatura-trace.json