A · Ham veri ve temizlik
Model bu dosyaları hiç görmedi; görse de ham hâlleri kirli. Bu metni olduğu gibi parçalara bölersek gürültü de parçaların içine girer.
Ham veri nedir?
Şirketin bilgisi PDF, Word ya da HTML dosyalarında durur. Bu dosyalardan çıkarılan metne ham veri diyoruz. İçeriği doğrudur ama dosyanın izlerini taşır: sayfa başlıkları, sayfa numaraları, menüler.
Bizim ham verimiz: hr-leave.md
Atölyede metni çıkarma işini atlıyoruz.
corpus/içindeki her dosya PDF’ten çıkmış metin gibi görünüyor. Sayfa başlıklarını, numaraları ve bölünmüş satırları bilerek ekledik; temizlenecek gerçek bir şey olsun diye.İki adım, model yok
1-load.tsdosyayı okur ve üstteki etiket bloğunu asıl metinden ayırır.2-clean.tsmetni temizler. İkisinde de model çalışmıyor, yalnızca bizim kodumuz.Üstteki blok: metadata
Dosyanın en üstünde, iki
---çizgisi arasında doküman kendini tanıtıyor: kimliği (id), sürümü (edition), bölümü, kimin okuyabileceği (access), dili (lang). Buna metadata diyoruz.1-load.tsbu bloğu alan alan saklar.npm run step -- 1, sonradata/1-docs.json:hr-salary-bandsdokümanını yalnızca İK’nın okuyabildiğini hangi alan gösteriyor?Gerçek bir PDF'te bu blok olur mu?
Olmaz. Gerçek projede sürümü ve okuma yetkisini dokümanın durduğu sistem tutar: SharePoint, Confluence ya da bir arşiv. Biz her şey tek dosyada dursun diye bunları dosyanın başına yazdık.
Metadata sonra filtre olur
hr-salary-bandsdokümanıaccess: hr-onlytaşıyor: yalnızca İK okuyabilir. Etiketi şimdi okuyoruz, soru anında kontrol ediyoruz (E sayfası). Etiket en baştan kayıtta olmazsa sonra filtrelenecek bir şey kalmaz.Alttaki metin: PDF'in izleri
hr-leave 4 sayfalık bir PDF’ten çıkmış gibi: her sayfanın başında aynı başlık ve gizlilik satırı, sonunda
Page 1 of 4gibi bir numara. Bu satırlar içerik değil; onlara gürültü diyoruz.Gürültü neden sorun?
Başlık satırı her sayfada tekrar eder; metni bölünce birçok parçanın içine girer. Bu ortak satır parçaları birbirine daha çok benzetir ve aramanın onları ayırt etmesini zorlaştırabilir.
Bir de bölünmüş satırlar
Sayfa genişliği cümleleri de bölmüş: “special” bir satırın sonunda, “leave” sonrakinin başında. İnsan için sorun yok; satır satır işleyen bir program için bunlar iki ayrı parça.
Temizlik: 4 kural ve bir birleştirici
2-clean.tsgürültüyü 4 kuralla (regex) atar: sayfa başlığı, İngilizce gizlilik satırı, İngilizce ve Türkçe sayfa numarası. Sonra bölünmüş satırları birleştirir; başlık, tablo ve boş satırlar kalır.src/steps/2-clean.tsiçindeNOISElistesinin ilk kuralını kapatın,npm run step -- 2:hr-leave−8 gürültü satırı göstermeli. Sonra kuralı geri açıpnpm run step -- 2komutunu tekrar çalıştırın.Sonuç: ham veri girdi, temiz metin çıktı
8 dokümanda 1078 satır 512 satıra indi. Eksilen 566 satırın 74’ü atılan gürültü; gerisi birleşen satırlar ve silinen fazla boş satırlar. İki adım birkaç milisaniye sürdü.
1-load.ts içinde: corpus → 1-docs.json
Class yok: her adım tek dosyada, düz fonksiyonlarla yazıldı.
npm run step -- 1,src/steps/1-load.tsdosyasını çalıştırır; 8 dosya okunur, 8 kayıtdata/1-docs.jsondosyasına yazılır.2-clean.ts içinde: 1-docs.json → 2-clean.json
2-clean.tskodu1-load.tskodunu çağırmaz; onun yazdığıdata/1-docs.jsondosyasını okur. Temizlenen metintextalanına girer, metadata olduğu gibi kopyalanır, sonuçdata/2-clean.jsondosyasına yazılır.Aşağıdaki “Kaçan gürültü nereye gider?” bölümünü okuyupturkishConfidentialkuralını yazın. Kontrol:npm run step -- 2.it-securitysatırı −8 yerine −12 gürültü satırı göstermeli.
Günün sorusunun cevabı §3’te. Ham dosyada §3’ün ilk paragrafı üç satıra bölünmüş; temizlikten sonra data/2-clean.json içinde tek satır.
## 3. Annual Paid Leave
Part-time staff get the same number as full-time staff. The HR portalshows the balance for each person. Questions go to the unit's HRbusiness partner.
| Years of service (full years) | Annual paid leave (working days) ||---|---|| 1 | 16 || 2 | 16 |1-load.ts · corpus/ → data/1-docs.json
Bölüm başlığı “1-load.ts · corpus/ → data/1-docs.json”run() işi loadCorpus() fonksiyonuna verir: her .md dosyası okunur, parseFrontMatter() ile ikiye ayrılır, dosya başına bir kayıt döner. Metadata alanlarından biri eksikse hata burada çıkar:
/** * What it does: turns each file of one edition into a document with labels and text. * * Read one edition of the corpus. * @param edition folder name under corpus/, e.g. "2025" * @returns one Doc per file: metadata from the front matter, body untouched */export async function loadCorpus(edition: string): Promise<Doc[]> { const files = await readEditionFiles(edition); return files.map(({ path, raw }) => { const { meta, body } = parseFrontMatter(raw); const missing = KEYS.filter((k) => !meta[k]); if (missing.length) throw new Error(`${path}: front matter is missing ${missing.join(", ")}`); return { ...(meta as unknown as DocMeta), path, body }; });}parseFrontMatter() için YAML kütüphanesi yok; key: value satırları yetiyor:
/** * What it does: splits a file into its labels on top (id, edition, access…) and its text. * * Split a file into its front matter (the `---` block on top) and its body. * Only `key: value` lines are supported — enough for our metadata, no YAML library needed. * @param raw the whole file, line endings already normalised to \n * @example parseFrontMatter('---\nid: hr-leave\nedition: "2025"\n---\nbody').meta.edition // "2025" */export function parseFrontMatter(raw: string): { meta: Record<string, string>; body: string } { const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!m) return { meta: {}, body: raw }; const meta: Record<string, string> = {}; for (const l of m[1]!.split("\n")) { const kv = l.match(/^(\w+):\s*(.*)$/); if (kv) meta[kv[1]!] = kv[2]!.trim().replace(/^["'](.*)["']$/, "$1"); } return { meta, body: m[2]! };}Dönen kayıt bir Doc: src/lib/types.ts içinde bir TypeScript interface’i, class değil. DocMeta üstteki bloğun alanları, body dosyanın geri kalanı:
/** Step 1 output: one file from corpus/<edition>/, front matter parsed, body untouched. */export interface Doc extends DocMeta { path: string; body: string;}writeStep() 8 kaydı data/1-docs.json içindeki data dizisine yazar. Üstteki hash ve inputHash alanları sayesinde 2-clean.ts eski bir dosyayla çalışmaz.
$ npm run step -- 1
━━ STEP 1/8 · LOAD IN corpus/2025/ WHAT read every file · front matter → metadata (id, edition, department, access, lang) · body kept as-is OUT 8 documents · 42986 characters announcement-meal-card en all 1277 chars 37 lines benefits en all 4875 chars 124 lines code-of-conduct en all 5371 chars 133 lines hr-leave en all 7776 chars 198 lines hr-salary-bands en hr-only 4785 chars 125 lines hr-working-hours en all 5941 chars 147 lines it-security tr all 6934 chars 163 lines travel-expenses en all 6027 chars 151 lines TIME 6 ms → data/1-docs.json2-clean.ts · data/1-docs.json → data/2-clean.json
Bölüm başlığı “2-clean.ts · data/1-docs.json → data/2-clean.json”src/steps/2-clean.ts içindeki run(), readStep() ile data/1-docs.json dosyasını okur. Her kayıt şu satırdan geçer:
const docs: CleanDoc[] = input.data.map(({ path: _path, body, ...meta }) => ({ ...meta, ...cleanText(body) }));path düşer (_path adıyla alınıp kullanılmaz), metadata (...meta) olduğu gibi kopyalanır, cleanText(body) temiz text alanını ve atılan satır sayısını (noiseLines) ekler. Kaydın şekli CleanDoc, types.ts içinde Doc’un hemen altında.
Birleştirmenin sınırı: başlık, tablo satırı, boş satır ve liste maddesi hiçbir satıra eklenmez.
/** * What it does: checks if a line is a heading, a table row, or a blank line. * * Headings, table rows and blank lines are complete on their own — nothing is joined onto them. */const closed = (l: string) => l.startsWith("#") || l.startsWith("|") || l.trim() === "";/** * What it does: checks if a line must start fresh, not be joined to the line before. * * Headings, table rows, blank lines and list items always start a new line. */const opens = (l: string) => closed(l) || /^\s*([-*]|\d+\.)\s/.test(l);
/** * What it does: drops noise lines, then joins lines that the page width broke apart. * * Remove noise lines, then re-join prose lines that the page width broke apart. * @param body a document body from step 1 * @returns the clean text and how many noise lines were dropped */export function cleanText(body: string, rules: RegExp[] = NOISE): { text: string; noiseLines: number } { const lines = body.split("\n"); const kept = lines.filter((l) => !isNoise(l, rules)); const out: string[] = []; for (const l of kept) { const prev = out[out.length - 1]; if (prev !== undefined && !closed(prev) && !opens(l)) out[out.length - 1] = `${prev} ${l.trim()}`; else out.push(l); } const text = out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); return { text, noiseLines: lines.length - kept.length };}Gürültü kuralları bir listede; // default işaretli satırlar varsayılan olarak açık. Boş turkishConfidential sizin işiniz:
// ── YOUR TURN ───────────────────────────────────────────────────────────────────────────────────// The Turkish document (it-security) repeats "GİZLİ – Yalnızca şirket içi kullanım içindir" under the page header.// No rule removes it yet: find it in data/2-clean.json (copy the İ and the – from there). Write the rule the way// the English confidentiality rule below is written. Check: npm run step -- 2 → it-security shows −12 noise lines, not −8.export const turkishConfidential: RegExp | undefined = undefined;
// TOGGLE noise rules — comment one out, re-run step 2, and find what it used to removeconst NOISE: RegExp[] = [ /^Kraken Air \| .+ \| (Sürüm|Edition) \d{4}$/, // default — page header, repeated on every page /^CONFIDENTIAL – .+$/, // default — English confidentiality line, under the page header /^Sayfa \d+ \/ \d+$/, // default — Turkish page number /^Page \d+ of \d+$/, // default — English page number
...(turkishConfidential ? [turkishConfidential] : []),];$ npm run step -- 2
━━ STEP 2/8 · CLEAN IN data/1-docs.json (8 documents, edition 2025) WHAT drop noise lines (4 rules) · re-join lines the page width broke OUT 1078 lines → 512 lines · 74 noise lines removed announcement-meal-card − 3 noise lines benefits − 9 noise lines code-of-conduct − 9 noise lines hr-leave −12 noise lines hr-salary-bands − 9 noise lines hr-working-hours −12 noise lines it-security − 8 noise lines travel-expenses −12 noise lines
before (hr-leave, first 6 lines): │ Kraken Air | Annual Leave Policy | Edition 2025 │ CONFIDENTIAL – Internal use only │ │ # Annual Leave Policy │ │ ## 1. Purpose and Scope after: │ # Annual Leave Policy │ │ ## 1. Purpose and Scope TIME 6 ms → data/2-clean.jsonKaçan gürültü nereye gider?
Bölüm başlığı “Kaçan gürültü nereye gider?”Türkçe doküman (it-security) her sayfada GİZLİ – Yalnızca şirket içi kullanım içindir satırını taşıyor ve onu atan kural yok. Log’da it-security yanında −8 var: 4 sayfa başlığı ve 4 sayfa numarası gitti, 4 gizlilik satırı kaldı. Kalan gürültü kaybolmaz: B sayfasının bölme yöntemiyle it-security@2025#0 adlı bir chunk olur ve içinde doküman başlığıyla bu satırdan başka bir şey yoktur. Gerçek bir içeriği yok; ama her chunk gibi bir vektör alır ve depoda yer tutar. “Gürültü neden sorun?” adımındaki sorun, tek bir chunk’ta.
1 · Ham veriye bakın. corpus/2025/hr-leave.md dosyasını açın: --- bloğunu, sayfa başlığını ve Page 1 of 4 satırını bulun. Sonra npm run step -- 1 ve data/1-docs.json: her dokümanın access ve lang alanlarına bakın. hr-salary-bands dokümanını yalnızca İK’nın okuyabildiğini hangi alan gösteriyor?
2 · Bir kuralı kapatın. src/steps/2-clean.ts içinde NOISE listesinin ilk satırını (sayfa başlığı kuralı) başına // koyarak kapatın ve npm run step -- 2 çalıştırın. Log’da hr-leave satırı −12 yerine −8 gürültü satırı göstermeli. data/2-clean.json içinde Edition 2025 arayın: başlık metne geri döndü. Satırı geri açıp npm run step -- 2 komutunu yeniden çalıştırın.
3 · Benzerini yazın. Aynı dosyada turkishConfidential boş. İngilizce satırın kuralı /^CONFIDENTIAL – .+$/ şeklinde. Türkçe gizlilik satırı için aynı türden bir kural yazın. İpucu: tire uzun tire (–), İ harfi noktalı; satırı dosyadan kopyalayın, GIZLI yazmayın. Sonra:
npm run step -- 2→it-securitysatırı −8 yerine −12 gürültü satırı göstermeli.data/2-clean.jsoniçindeGİZLİarayın: satır artık yok.- İsterseniz
npm run step -- 3vedata/3-chunks.json:it-security@2025#0artık yok.
Kural yazılınca 2-clean.ts, 74 yerine 78 gürültü satırı atar ve 1078 satır 504 satıra iner. Sonraki sayfaların log’ları kural yazılmadan alındı: log 60 chunk (iki sürümle 120 kayıt) gösterir, sizde 59 chunk (118 kayıt) olur; bu fark beklenen.
Geride kaldıysanız: npm run catchup -- 2.
Sık sorulan
Bölüm başlığı “Sık sorulan”edition neden tırnak içinde yazılmış?
Filtre metinleri karşılaştırır: "2025" metni ile 2025 sayısı eşleşmez. Tırnak, bir YAML okuyucusunun değeri sayıya çevirmesini engeller; bizim küçük okuyucu her değeri zaten metin olarak alıyor.
Bir temizlik kuralı yanlışlıkla içerik silerse?
Bu yüzden kurallar satırın başına ve sonuna bağlı (^ … $): yalnızca bütün satır kalıba uyarsa silinir. turkishConfidential kuralınızda da bu bağları koruyun: kural, “Gizli veriler şifrelenmelidir.” cümlesini silmemeli.