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| Kural | Detay |
|---|---|
| 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ır | Kökün dışına çıkan her yol reddedilir: io path cannot escape the project directory: ../x |
| Boş yol | io path expects a non-empty string. |
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
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
{
"path": "0213/page-1.png",
"mime_type": "image/png",
"base64": "iVBORw0KGgoAAAANSUhEUg…",
"size": 184213
}MIME eşlemesi
| Uzantı | MIME |
|---|---|
.png | image/png |
.jpg · .jpeg | image/jpeg |
.webp | image/webp |
.gif | image/gif |
.svg | image/svg+xml |
.pdf | application/pdf |
.md | text/markdown |
.txt | text/plain |
.json | application/json |
| diğer | application/octet-stream |
io.writeText hedefin ara klasörlerini otomatik oluşturur — çıktı klasörünü önceden hazırlamana gerek yoktur.
Dosya listeleme
pages = io.listFiles("@0213", "*.png", false)
// ["@0213/page-1.png", "@0213/page-2.png", "@0213/page-10.png"]| Parametre | Kural |
|---|---|
directory | Klasör olmalıdır; dosya verilirse io listFiles expects a directory. |
pattern | Yalnızca dosya adı glob'u: * (herhangi) ve ? (tek karakter). / veya \ içeremez. Büyük/küçük harf duyarsızdır. |
recursive | true ise alt klasörlere iner. |
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.
pages = io.listFiles("@0213", "*.png", false)
for page in pages {
...
}// 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>}}// 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}" -> TranscriptBu 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.
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:…}} |
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: { … } } |
Üç 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- 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 } - Çıktı schema'sını alan açıklamalarıyla birlikte yaz.
@descmetinleri JSON Schema'ya girer ve model için extraction talimatı işlevi görür.lumaschema 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") } - Sayfaları tek tek transkribe et. Her sayfayı ayrı bir
askile işlemek, tek istekte tüm sayfaları göndermekten belirgin şekilde daha güvenilirdir: token limiti aşılmaz ve model sayfaları karıştırmaz.lumaagent 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 } } } - 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 } } - 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?
| Karar | Gerekçe |
|---|---|
Sayfa başına ayrı ask | Token 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 alanlar | Modele "bulamadım" demesi için açık bir yol verir; uydurma değerleri azaltır. |
Kalem düzeyinde where | Aritmetik tutarsızlığı yakalar ve hata mesajını modele geri besler. |
| İki ayrı model profili | Görsel modeli pahalıdır ve dar bağlamlıdır; çıkarım modeli ucuzdur ve geniş bağlam ister. |
Ü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