feat: derin analiz & yol haritasi + ihale dosyasi hazirlama; docx/xlsx/zip okunuyor
Doküman panelindeki "Tum Evraki Derin Analiz Et" dugmesi EKAP ilan metnini ve
ihaleye yuklenmis butun belgeleri TEK metinde birlestirip gonderiyor; model
sartnameyi ilanla ve eklerle capraz okuyabiliyor.
Derin analiz (mode='derin', ayri sistem istemi + 8000 token):
- Teklifi gecersiz kilabilecek noktalar (kirmizi uyari, en ustte)
- Gozden kacan ince detaylar (Kritik/Yuksek/Orta + madde no + neden onemli)
- Alinacak kalemler: aranan evsaflar, miktar, sartnamede geciyorsa fiyat
(modele "TAHMIN URETME" talimati verildi, yoksa null)
- Belge kontrol listesi, kritik tarihler, maliyet kalemleri
- Teklife kadar yol haritasi (faz / kim / ne zaman / cikti)
- Idareye sorulacak belirsizlikler
Standart hizli ozet modu aynen duruyor.
Ihale Dosyasi Hazirla (rapor sonunda, acilir bolum):
- Eksik firma bilgileri kullaniciya sorulur, profilden gelenler sorulmaz,
girilenler tarayicida saklanir
- Kalem fiyatlandirma tablosu, canli toplam + yaziyla karsiligi
- Teklif Mektubu, Birim Fiyat Teklif Cetveli, Evrak Kontrol Listesi
- Belgeler yazdirilabilir HTML: jsPDF'in gomulu fontlari WinAnsi, s/g/i/I
tasimiyor (bkz. services/pdf.js ASCII basliklari) — resmi teklif mektubunda
bozuk Turkce kabul edilemez
docx/xlsx/zip metin cikarimi (server/office-text.mjs):
- ihaleab projesindeki officeText.ts'ten tasindi, harici bagimlilik yok
- Sunucuda: tarayicida cozucu yok, baytlar zaten BELGE_DIR'de
- Tablolar " | " ile korunuyor, ic ice zip aciliyor, ~$ gecici dosyalari atlanir
- Yeni uc: GET /api/me/documents/text
- Yukleme kabul listesi .doc/.rar cikarildi (karsiligi yok), .xlsx eklendi
fix: yuklenen belgelerdeki "AI analiz" dugmesi bozuktu — extractText nesne
donduruyor ({text,pageCount,usedPages}), kod onu duz metin sanip gonderiyordu;
sunucuya "[object Object]" gidiyor ve analiz reddediliyordu. Uzunluk kontrolu
de nesne uzerinde undefined verdigi icin hatayi yutuyordu.
fix: analiz dokuman panelinden baslatildiginda modal kendi isAnalyzing'ini
bilmiyordu, kullanici 1-2 dakika bos modal goruyordu — ortak aiAnalyzing sinyali
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* DOCX / XLSX / ZIP metin çıkarımı — harici bağımlılık YOK.
|
||||
* ihaleab projesindeki backend/src/lib/officeText.ts dosyasından taşındı.
|
||||
*
|
||||
* NEDEN SUNUCUDA: ihaleist'te PDF metni tarayıcıda (pdfjs) çıkarılıyor, ama
|
||||
* docx/xlsx/zip için tarayıcıda çözücü yok. Belgelerin baytları zaten
|
||||
* sunucuda duruyor (BELGE_DIR), Node'un yerleşik zlib'i de burada.
|
||||
*/
|
||||
// ZIP / DOCX / XLSX metin çıkarımı — harici bağımlılık YOK.
|
||||
//
|
||||
// NEDEN: ihale şartnameleri pratikte .docx ve .xlsx olarak yayımlanıyor ve
|
||||
// çoğu zaman tek bir .zip arşivinin içinde geliyor (ör. Letonya EIS'te
|
||||
// "Nolikums ar pielikumiem.zip" — 17 dosya, içinde 12 kısmın teknik
|
||||
// şartnamesi ve mali teklif formu). pdf-parse yalnızca PDF okuyabildiği için
|
||||
// bu dosyalardan hiç metin çıkarılamıyordu; aranan birim miktarlar
|
||||
// (kaç adet polo gömlek, kaç çift bot) tam olarak bu dosyaların içinde.
|
||||
//
|
||||
// TASARIM: OOXML dosyalarının tamamı ZIP arşividir ve içerikleri düz XML'dir.
|
||||
// Node'un yerleşik zlib'i ile küçük bir ZIP okuyucu yazmak, yeni bir paket
|
||||
// eklemekten daha az risk taşıyor (projede zaten HTML/XML için regex tabanlı
|
||||
// yaklaşım benimsenmiş, bkz. tenderDocuments.ts stripHtml).
|
||||
//
|
||||
// SINIRLAR: ZIP64 arşivleri ve şifreli arşivler desteklenmez — bu durumlarda
|
||||
// anlamlı bir hata döner, sessizce bozuk veri üretilmez.
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
|
||||
const EOCD_SIG = 0x06054b50;
|
||||
const CENTRAL_SIG = 0x02014b50;
|
||||
const LOCAL_SIG = 0x04034b50;
|
||||
|
||||
const MAX_ENTRIES = 400;
|
||||
const MAX_ENTRY_BYTES = 80 * 1024 * 1024;
|
||||
|
||||
|
||||
/**
|
||||
* ZIP arşivinin merkezî dizinini okuyup girdileri döndürür. İçerik tembel
|
||||
* açılır (read()) — 17 dosyalık bir arşivde yalnızca ihtiyaç duyulanlar
|
||||
* açılsın diye.
|
||||
*/
|
||||
export function readZip(buf) {
|
||||
const eocd = findEocd(buf);
|
||||
if (eocd < 0) throw new Error("ZIP dizini bulunamadı (geçersiz veya ZIP64 arşiv).");
|
||||
|
||||
const entryCount = buf.readUInt16LE(eocd + 10);
|
||||
const centralOffset = buf.readUInt32LE(eocd + 16);
|
||||
if (centralOffset >= buf.length) throw new Error("ZIP merkezî dizin konumu geçersiz.");
|
||||
|
||||
const entries = [];
|
||||
let p = centralOffset;
|
||||
|
||||
for (let i = 0; i < Math.min(entryCount, MAX_ENTRIES); i++) {
|
||||
if (p + 46 > buf.length || buf.readUInt32LE(p) !== CENTRAL_SIG) break;
|
||||
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const flags = buf.readUInt16LE(p + 8);
|
||||
const compSize = buf.readUInt32LE(p + 20);
|
||||
const uncompSize = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = decodeName(buf.subarray(p + 46, p + 46 + nameLen), flags);
|
||||
|
||||
const isDirectory = name.endsWith("/") || uncompSize === 0;
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
size: uncompSize,
|
||||
isDirectory,
|
||||
read: () => {
|
||||
if (flags & 0x1) throw new Error(`Şifreli arşiv girdisi okunamaz: ${name}`);
|
||||
if (compSize > MAX_ENTRY_BYTES) throw new Error(`Arşiv girdisi çok büyük: ${name}`);
|
||||
if (localOffset + 30 > buf.length || buf.readUInt32LE(localOffset) !== LOCAL_SIG) {
|
||||
throw new Error(`Arşiv girdisi konumu geçersiz: ${name}`);
|
||||
}
|
||||
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(start, start + compSize);
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) return inflateRawSync(raw);
|
||||
throw new Error(`Desteklenmeyen sıkıştırma yöntemi (${method}): ${name}`);
|
||||
},
|
||||
});
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** EOCD imzasını sondan geriye doğru arar (yorum alanı en fazla 64KB olabilir). */
|
||||
function findEocd(buf) {
|
||||
const min = Math.max(0, buf.length - 65_557);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** ZIP adları UTF-8 bayrağı yoksa CP437'dir; pratikte UTF-8 denemesi yeterli. */
|
||||
function decodeName(raw, flags) {
|
||||
if (flags & 0x800) return raw.toString("utf8");
|
||||
const asUtf8 = raw.toString("utf8");
|
||||
return asUtf8.includes("�") ? raw.toString("latin1") : asUtf8;
|
||||
}
|
||||
|
||||
/* ---------------- XML yardımcıları ---------------- */
|
||||
|
||||
function decodeXmlEntities(s) {
|
||||
return s
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function stripTags(xml) {
|
||||
return decodeXmlEntities(xml.replace(/<[^>]+>/g, "")).replace(/[ \t]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function findEntry(entries, path) {
|
||||
return entries.find((e) => e.name === path || e.name === path.replace(/^\//, "")) ?? null;
|
||||
}
|
||||
|
||||
/* ---------------- DOCX ---------------- */
|
||||
|
||||
/**
|
||||
* DOCX'ten metin çıkarır. Tablolar korunur: hücreler " | ", satırlar yeni
|
||||
* satır. Miktar tabloları düz metne ezilirse hangi sayının hangi kaleme ait
|
||||
* olduğu kaybolduğu için bu şart.
|
||||
*/
|
||||
export function docxToText(buf) {
|
||||
const entries = readZip(buf);
|
||||
const doc = findEntry(entries, "word/document.xml");
|
||||
if (!doc) throw new Error("DOCX içinde word/document.xml bulunamadı.");
|
||||
|
||||
let xml = doc.read().toString("utf8");
|
||||
|
||||
// Hücre/satır sınırları ÖNCE işaretleyiciye çevrilir. Doğrudan " | " ve "\n"
|
||||
// yazılamaz: bir tablo hücresi kendi içinde <w:p> barındırdığı için paragraf
|
||||
// sonu ayracı kendi satırına düşürür ve satır temizliğinde silinirdi.
|
||||
const CELL = "";
|
||||
const ROW = "";
|
||||
|
||||
xml = xml
|
||||
.replace(/<w:br\b[^>]*\/?>/g, "\n")
|
||||
.replace(/<w:tab\b[^>]*\/?>/g, "\t")
|
||||
.replace(/<\/w:tc>/g, CELL)
|
||||
.replace(/<\/w:tr>/g, ROW)
|
||||
.replace(/<\/w:p>/g, "\n");
|
||||
|
||||
const duz = stripTagsPreservingBreaks(xml)
|
||||
// İşaretleyicinin etrafındaki boşluk/satır sonlarını yut.
|
||||
.replace(new RegExp(`\\s*${CELL}\\s*`, "g"), " | ")
|
||||
.replace(new RegExp(`\\s*${ROW}\\s*`, "g"), "\n");
|
||||
|
||||
return tidyLines(duz);
|
||||
}
|
||||
|
||||
/** stripTags'in satır sonlarını koruyan hali. */
|
||||
function stripTagsPreservingBreaks(xml) {
|
||||
return decodeXmlEntities(xml.replace(/<[^>]+>/g, ""));
|
||||
}
|
||||
|
||||
function tidyLines(text) {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((l) => l.replace(/[ \t ]+/g, " ").replace(/\s*\|\s*/g, " | ").trim())
|
||||
.map((l) => l.replace(/^\|\s*/, "").replace(/\s*\|$/, "").trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/* ---------------- XLSX ---------------- */
|
||||
|
||||
/** A1 -> 0, B1 -> 1 ... (sütun harflerini indekse çevirir) */
|
||||
function colIndex(ref) {
|
||||
const m = /^([A-Z]+)/.exec(ref);
|
||||
if (!m) return 0;
|
||||
let n = 0;
|
||||
for (const ch of m[1]) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
function parseSharedStrings(entries) {
|
||||
const e = findEntry(entries, "xl/sharedStrings.xml");
|
||||
if (!e) return [];
|
||||
const xml = e.read().toString("utf8");
|
||||
const out = [];
|
||||
// Her <si> bir dize; içinde birden çok <t> parçası olabilir (biçimli metin).
|
||||
for (const si of xml.split(/<si\b[^>]*>/).slice(1)) {
|
||||
const govde = si.split("</si>")[0] ?? "";
|
||||
const parcalar = [...govde.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map((m) => m[1]);
|
||||
out.push(decodeXmlEntities(parcalar.join("")).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sheetNames(entries) {
|
||||
const wb = findEntry(entries, "xl/workbook.xml");
|
||||
if (!wb) return [];
|
||||
const xml = wb.read().toString("utf8");
|
||||
return [...xml.matchAll(/<sheet\b[^>]*\bname="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* XLSX'ten metin çıkarır. Her sayfa "### <sayfa adı>" başlığıyla, satırlar
|
||||
* " | " ayraçlı olarak döner — mali teklif formundaki kalem/miktar tabloları
|
||||
* bu biçimde okunabilir kalıyor.
|
||||
*/
|
||||
export function xlsxToText(buf) {
|
||||
const entries = readZip(buf);
|
||||
const shared = parseSharedStrings(entries);
|
||||
const adlar = sheetNames(entries);
|
||||
|
||||
const sayfalar = entries
|
||||
.filter((e) => /^xl\/worksheets\/sheet\d+\.xml$/.test(e.name))
|
||||
.sort((a, b) => {
|
||||
const na = Number(/sheet(\d+)\.xml/.exec(a.name)?.[1] ?? 0);
|
||||
const nb = Number(/sheet(\d+)\.xml/.exec(b.name)?.[1] ?? 0);
|
||||
return na - nb;
|
||||
});
|
||||
if (sayfalar.length === 0) throw new Error("XLSX içinde çalışma sayfası bulunamadı.");
|
||||
|
||||
const bloklar = [];
|
||||
|
||||
sayfalar.forEach((sheet, idx) => {
|
||||
const xml = sheet.read().toString("utf8");
|
||||
const satirlar = [];
|
||||
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
const hucreler = [];
|
||||
for (const c of rowMatch[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attrs = c[1];
|
||||
const govde = c[2];
|
||||
const ref = /\br="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? "";
|
||||
const tip = /\bt="([^"]*)"/.exec(attrs)?.[1] ?? "";
|
||||
const vRaw = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(govde)?.[1];
|
||||
|
||||
let deger = "";
|
||||
if (tip === "s") {
|
||||
const i = Number(vRaw);
|
||||
deger = Number.isInteger(i) ? shared[i] ?? "" : "";
|
||||
} else if (tip === "inlineStr") {
|
||||
deger = stripTags(govde);
|
||||
} else if (vRaw != null) {
|
||||
deger = decodeXmlEntities(vRaw).trim();
|
||||
}
|
||||
|
||||
if (!deger) continue;
|
||||
const i = ref ? colIndex(ref) : hucreler.length;
|
||||
while (hucreler.length < i) hucreler.push("");
|
||||
hucreler[i] = deger;
|
||||
}
|
||||
|
||||
// Kendi kendine boş satırları atla.
|
||||
const satir = hucreler.join(" | ").replace(/(\s*\|\s*)+$/, "").trim();
|
||||
if (satir.replace(/[|\s]/g, "")) satirlar.push(satir);
|
||||
}
|
||||
|
||||
if (satirlar.length > 0) {
|
||||
bloklar.push(`### ${adlar[idx] ?? `Sayfa ${idx + 1}`}\n${satirlar.join("\n")}`);
|
||||
}
|
||||
});
|
||||
|
||||
return bloklar.join("\n\n").trim();
|
||||
}
|
||||
|
||||
/* ---------------- Dağıtıcı ---------------- */
|
||||
|
||||
export function isDocx(name, buf) {
|
||||
return /\.docx$/i.test(name) || (/\.doc$/i.test(name) === false && hasEntry(buf, "word/document.xml"));
|
||||
}
|
||||
|
||||
export function isXlsx(name) {
|
||||
return /\.xlsx$/i.test(name);
|
||||
}
|
||||
|
||||
export function isZip(name, buf) {
|
||||
return /\.zip$/i.test(name) || (buf.length > 4 && buf.readUInt32LE(0) === LOCAL_SIG);
|
||||
}
|
||||
|
||||
function hasEntry(buf, path) {
|
||||
try {
|
||||
return readZip(buf).some((e) => e.name === path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dosya adına ve içeriğine bakarak uygun çıkarıcıyı seçer. Tanınmayan tür
|
||||
* için null döner (çağıran taraf PDF/HTML yollarına düşebilsin diye).
|
||||
*/
|
||||
export function officeToText(name, buf) {
|
||||
try {
|
||||
if (isXlsx(name)) return xlsxToText(buf);
|
||||
if (/\.docx$/i.test(name)) return docxToText(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------- Arşiv (zip) ---------------- */
|
||||
|
||||
// Derin analiz zaten 90.000 karakter okuyor; tek bir arşivin bunun tamamını
|
||||
// yemesini istemiyoruz — birden çok belge birleştiriliyor.
|
||||
const MAX_ARSIV_KARAKTER = 60000;
|
||||
|
||||
/**
|
||||
* ZIP/DOCX/XLSX tamponundan metin çıkarır. Arşivse içindeki Office dosyaları
|
||||
* tek tek çözülüp "## <dosya adı>" başlıklarıyla birleştirilir. İhale
|
||||
* paketleri iç içe zip olabildiği için bir seviye iç arşiv de açılır.
|
||||
*/
|
||||
export function arsivMetni(name, buf, depth = 0) {
|
||||
const direct = officeToText(name, buf);
|
||||
if (direct) return direct;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readZip(buf);
|
||||
} catch (e) {
|
||||
throw new Error(`Arşiv okunamadı: ${String(e.message || e).slice(0, 160)}`);
|
||||
}
|
||||
|
||||
const bloklar = [];
|
||||
let toplam = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (toplam >= MAX_ARSIV_KARAKTER) break;
|
||||
if (entry.isDirectory) continue;
|
||||
// Word'ün geçici kilit dosyaları (~$...) içerik taşımaz.
|
||||
if (/(^|\/)~\$/.test(entry.name)) continue;
|
||||
|
||||
const kisaAd = entry.name.split('/').pop() ?? entry.name;
|
||||
let metin = null;
|
||||
try {
|
||||
const icerik = entry.read();
|
||||
if (depth < 1 && /\.zip$/i.test(entry.name)) {
|
||||
metin = arsivMetni(kisaAd, icerik, depth + 1);
|
||||
} else {
|
||||
metin = officeToText(entry.name, icerik);
|
||||
}
|
||||
} catch {
|
||||
continue; // tek bozuk dosya tüm paketi düşürmesin
|
||||
}
|
||||
|
||||
if (!metin) continue;
|
||||
bloklar.push(`## ${kisaAd}\n${metin}`);
|
||||
toplam += metin.length;
|
||||
}
|
||||
|
||||
if (bloklar.length === 0) throw new Error('Arşiv içinde okunabilir doküman bulunamadı.');
|
||||
return bloklar.join('\n\n').slice(0, MAX_ARSIV_KARAKTER);
|
||||
}
|
||||
|
||||
/** Sunucunun metin çıkarabildiği uzantılar (PDF tarayıcıda okunuyor). */
|
||||
export function sunucuOkuyabilir(ad) {
|
||||
return /\.(docx|xlsx|zip)$/i.test(String(ad || ''));
|
||||
}
|
||||
Reference in New Issue
Block a user