diff --git a/server/ai.mjs b/server/ai.mjs index 751a76a..65c3e1e 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -100,7 +100,75 @@ YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema: } Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`; -function buildUserPrompt({ text, fileName, tender }) { +/** + * Derin analiz — teknik şartname + idari şartname + sözleşme tasarısı için. + * Standart analizden farkı: gözden kaçan "ince detayları", teklifi geçersiz + * kılan sebepleri, belge kontrol listesini ve teklife kadarki yol haritasını + * ayrı ayrı üretir. + */ +export const DERIN_SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734 sayılı Kamu İhale Kanunu, 4735 sayılı Kamu İhale Sözleşmeleri Kanunu ve ikincil mevzuat) konusunda çok deneyimli bir ihale danışmanısın. +Sana verilen teknik şartname / idari şartname / sözleşme tasarısı / ihale ilanı metnini madde madde incele. +İki şey üret: (1) derinlemesine analiz, (2) teklif anına kadar izlenecek somut yol haritası. + +Özellikle şunları ara — ihaleyi kaybettiren ama gözden kaçan detaylar bunlardır: +- Teklif zarfında eksikse doğrudan değerlendirme dışı bırakılma sebepleri +- Belgelerin tarih/süre koşulları (son X ay içinde alınmış, ihale tarihinden önceki Y gün, geçerlilik süresi) +- Geçici/kesin teminat oranı, süresi, kabul edilen teminat türleri +- Numune / demo / katalog / teknik doküman sunma zorunluluğu, teslim yeri ve anı +- Yerli malı belgesi ve fiyat avantajı oranı +- İş deneyim belgesi oranı, benzer iş tanımı, iş ortaklığında pilot ortak payı +- Birim fiyat mı anahtar teslim götürü bedel mi; aritmetik hatanın sonucu +- Kısmi teklif verilebilir mi, yeterlik kısım bazında mı aranıyor +- Teslim süresi, montaj / eğitim / garanti / yedek parça yükümlülükleri +- Gecikme cezası oranı, fesih ve yasaklama sonuçları +- Fiyat farkı verilip verilmeyeceği +- Zeyilname / açıklama talebi ve şikâyet başvurusu süreleri +- Alınacak ürün/hizmetin belirleyici teknik evsafları, miktarları ve varsa birim fiyatları + +YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema: +{ + "summary": "3-5 cümlelik yönetici özeti", + "scopeOfWork": "işin kapsamı: ne alınıyor/yaptırılıyor, miktar, süre, yer", + "disqualificationRisks": ["teklifin doğrudan değerlendirme dışı kalmasına yol açacak durumlar (en fazla 8)"], + "criticalDetails": [ + {"level":"Kritik|Yüksek|Orta","title":"kısa başlık","where":"madde no veya bölüm","detail":"ince detayın kendisi","whyItMatters":"gözden kaçarsa ne olur"} + ], + "itemsAndSpecs": [ + {"name":"kalem/ürün adı","quantity":"miktar ve birim (ör. 3 adet)","keySpecs":["teklifi belirleyen zorunlu teknik evsaf"],"estimatedUnitPrice":sayı_veya_null,"estimatedTotal":sayı_veya_null,"note":"marka/eşdeğer, garanti, montaj gibi kaleme özel not"} + ], + "qualificationCriteria": ["istenen yeterlik belgeleri, madde madde (en fazla 15)"], + "criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar (en fazla 12)"], + "documentChecklist": [ + {"document":"belge adı","mandatory":true,"note":"nereden/nasıl temin edilir, tarih veya geçerlilik koşulu"} + ], + "deadlines": [{"label":"neyin süresi","value":"tarih veya süre","note":"kaçırılırsa sonucu"}], + "costDrivers": ["maliyeti asıl belirleyen kalemler (en fazla 8)"], + "risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}], + "roadmap": [ + {"step":1,"phase":"Hazırlık|Belge Toplama|Teknik|Fiyatlandırma|Teklif|Sözleşme","action":"yapılacak somut iş","owner":"kim yapmalı","timing":"ne zaman / hangi tarihe kadar","output":"ortaya çıkacak çıktı"} + ], + "questionsForAdministration": ["idareye sorulması / zeyilname istenmesi gereken belirsizlikler (en fazla 8)"], + "bidAnalysis": { + "estimatedCost": sayı_veya_null, + "recommendedBid": sayı_veya_null, + "minBidLimit": sayı_veya_null, + "maxBidLimit": sayı_veya_null, + "winProbability": 0-100_arası_sayı_veya_null, + "competitionLevel": "rekabet seviyesi yorumu", + "notes": "teklif stratejisi notu" + } +} + +Kurallar: +- criticalDetails en fazla 12, documentChecklist en fazla 18, deadlines en fazla 10, roadmap en fazla 10, itemsAndSpecs en fazla 20 madde olsun. +- itemsAndSpecs'i teknik şartnamedeki kalem/miktar tablolarından çıkar; keySpecs'e yalnızca teklifi bağlayan zorunlu evsafları yaz (ör. "en az 17x17 inç dedektör", "IP54"), tanıtım cümlelerini yazma. +- estimatedUnitPrice / estimatedTotal yalnızca metinde geçen bir yaklaşık maliyet, tahmini bedel veya birim fiyat varsa doldurulur; TAHMİN ÜRETME, yoksa null bırak. Sayıları yalnızca rakam olarak yaz (para birimi simgesi, nokta/virgül ayracı koyma). +- roadmap adımlarını mantıklı sırayla ve step alanını 1'den başlayarak numaralandır. +- Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan. +- Madde/bölüm numarası metinde geçiyorsa "where" ve "clause" alanlarında mutlaka yaz. +- Tüm metinleri Türkçe yaz.`; + +function buildUserPrompt({ text, fileName, tender, limit = 60000 }) { const parts = []; if (fileName) parts.push(`Dosya: ${fileName}`); if (tender) { @@ -113,12 +181,12 @@ function buildUserPrompt({ text, fileName, tender }) { parts.push('Kalemler: ' + tender.items.map((i) => `${i.ad} (${i.miktar} ${i.birim})`).join('; ')); } } - parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, 60000)); + parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, limit)); return parts.join('\n'); } // --- OpenRouter (OpenAI uyumlu) --- -async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT) { +async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) { const res = await fetch(OPENROUTER_URL, { method: 'POST', headers: { @@ -135,7 +203,7 @@ async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PR { role: 'user', content: userPrompt }, ], temperature: 0.2, - max_tokens: 4000, + max_tokens: maxTokens, response_format: { type: 'json_object' }, }), signal, @@ -146,7 +214,7 @@ async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PR return data?.choices?.[0]?.message?.content || ''; } -async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) { +async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) { const res = await fetch(p.url, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, @@ -157,7 +225,7 @@ async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTE { role: 'user', content: userPrompt }, ], temperature: 0.2, - max_tokens: 4000, + max_tokens: maxTokens, }), signal, }); @@ -166,7 +234,7 @@ async function callOpenAiStyle(p, key, model, userPrompt, signal, sistem = SYSTE return data?.choices?.[0]?.message?.content || ''; } -async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) { +async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) { const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`; const res = await fetch(url, { method: 'POST', @@ -174,7 +242,7 @@ async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PRO body: JSON.stringify({ systemInstruction: { parts: [{ text: sistem }] }, contents: [{ role: 'user', parts: [{ text: userPrompt }] }], - generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' }, + generationConfig: { temperature: 0.2, maxOutputTokens: maxTokens, responseMimeType: 'application/json' }, }), signal, }); @@ -183,13 +251,13 @@ async function callGemini(p, key, model, userPrompt, signal, sistem = SYSTEM_PRO return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || ''; } -async function callAnthropic(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT) { +async function callAnthropic(p, key, model, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) { const res = await fetch(p.url, { method: 'POST', headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }, body: JSON.stringify({ model, - max_tokens: 4000, + max_tokens: maxTokens, system: sistem, messages: [{ role: 'user', content: userPrompt }], }), @@ -212,11 +280,16 @@ function extractJson(raw) { } /** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ -export async function analyzeSpec({ text, fileName, tender, chain, systemOverride }) { +export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode }) { + const derin = mode === 'derin'; const order = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude']) .filter((id) => PROVIDERS[id]); - const userPrompt = buildUserPrompt({ text, fileName, tender }); - const sistem = systemOverride || SYSTEM_PROMPT; + // Derin analiz hem daha çok metin okur hem daha uzun JSON üretir; çıktı + // kesilirse JSON parse edilemez ve zincir boşuna tüketilir. + const userPrompt = buildUserPrompt({ text, fileName, tender, limit: derin ? 90000 : 60000 }); + const sistem = systemOverride || (derin ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT); + const maxTokens = derin ? 8000 : 4000; + const timeoutMs = derin ? 150000 : 90000; const attempts = []; for (const id of order) { @@ -227,22 +300,23 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid continue; } const ac = new AbortController(); - const timer = setTimeout(() => ac.abort(), 90000); + const timer = setTimeout(() => ac.abort(), timeoutMs); try { let raw; if (route.yol === 'openrouter') { - raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem); + raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem, maxTokens); } else if (p.style === 'gemini') { - raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem); + raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); } else if (p.style === 'anthropic') { - raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal, sistem); + raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); } else { - raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem); + raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens); } const result = extractJson(raw); attempts.push({ id, ok: true, yol: route.yol }); return { result, + mode: derin ? 'derin' : 'standart', model: { id, label: p.label, provider: p.provider, model: route.model, yol: route.yol }, attempts, }; diff --git a/server/index.js b/server/index.js index 5785aef..5b28b41 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,7 @@ import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; +import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs'; import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs'; import * as store from './user-store.mjs'; @@ -786,6 +787,24 @@ const server = http.createServer(async (req, res) => { return json(res, 500, { success: false, error: 'dosya okunamadı' }); } } + // Belge metni — docx/xlsx/zip için. PDF tarayıcıda (pdfjs) okunuyor; + // bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor. + if (url.pathname === '/api/me/documents/text') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + const rec = store.getDocument(user.email, url.searchParams.get('id')); + if (!rec) return json(res, 404, { success: false, error: 'belge bulunamadı' }); + if (!sunucuOkuyabilir(rec.ad)) { + return json(res, 400, { success: false, error: 'Bu dosya türünden sunucuda metin çıkarılamıyor (docx, xlsx, zip destekleniyor).' }); + } + try { + const buf = fs.readFileSync(path.join(store.BELGE_DIR, rec.dosya)); + const metin = arsivMetni(rec.ad, buf); + return json(res, 200, { success: true, ad: rec.ad, metin, karakter: metin.length }); + } catch (e) { + return json(res, 422, { success: false, error: e.message }); + } + } // --- Kullanıcı verisi (localStorage yerine sunucu tarafı) --- if (url.pathname.startsWith('/api/me/data')) { @@ -973,6 +992,7 @@ const server = http.createServer(async (req, res) => { fileName: body.fileName, tender: body.tender, chain: body.chain, + mode: body.mode === 'derin' ? 'derin' : undefined, }); return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() }); } catch (e) { diff --git a/server/office-text.mjs b/server/office-text.mjs new file mode 100644 index 0000000..ac5cbb9 --- /dev/null +++ b/server/office-text.mjs @@ -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 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(/]*\/?>/g, "\n") + .replace(/]*\/?>/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 bir dize; içinde birden çok parçası olabilir (biçimli metin). + for (const si of xml.split(/]*>/).slice(1)) { + const govde = si.split("")[0] ?? ""; + const parcalar = [...govde.matchAll(/]*>([\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(/]*\bname="([^"]*)"/g)].map((m) => decodeXmlEntities(m[1])); +} + +/** + * XLSX'ten metin çıkarır. Her sayfa "### " 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(/]*>([\s\S]*?)<\/row>/g)) { + const hucreler = []; + for (const c of rowMatch[1].matchAll(/]*)>([\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 = /]*>([\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 "## " 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 || '')); +} diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index e0c06ff..17f33e8 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -3,13 +3,14 @@ import { useApp } from '../context/AppContext'; import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react'; import confetti from 'canvas-confetti'; import { getAiConfig } from '../services/ai'; +import { DeepAnalysisReport } from './DeepAnalysisReport'; const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleString('tr-TR') : '—'); export const AiSpecAnalyzerModal = () => { const { isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, - analyzeSpecPdfWithGemini, aiAnalysisResults, activeAiModel, activeTenderDetail, + analyzeSpecPdfWithGemini, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail, } = useApp(); const [isAnalyzing, setIsAnalyzing] = useState(false); @@ -18,6 +19,9 @@ export const AiSpecAnalyzerModal = () => { const [progress, setProgress] = useState(''); const [customBid, setCustomBid] = useState(null); const [aiCfg, setAiCfg] = useState(null); + // Derin mod: ince detaylar, belge kontrol listesi ve yol haritası da üretilir. + // Çıktısı standart şemanın üst kümesi olduğu için varsayılan açık. + const [derin, setDerin] = useState(true); useEffect(() => { if (isAiSpecAnalyzerOpen && aiCfg === null) getAiConfig().then(setAiCfg); @@ -25,6 +29,9 @@ export const AiSpecAnalyzerModal = () => { if (!isAiSpecAnalyzerOpen) return null; + // Analiz doküman panelinden de başlatılabiliyor — o durumda isAnalyzing false + // kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz. + const mesgul = isAnalyzing || aiAnalyzing; const results = aiAnalysisResults; const bid = results?.bidAnalysis || {}; @@ -33,8 +40,8 @@ export const AiSpecAnalyzerModal = () => { setIsAnalyzing(true); setSelectedFile(fileName); try { - setProgress('Model analiz ediyor…'); - const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail }); + setProgress(derin ? 'Derin analiz üretiliyor (1-2 dk sürebilir)…' : 'Model analiz ediyor…'); + const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail, mode: derin ? 'derin' : undefined }); if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid); confetti({ particleCount: 80, spread: 70 }); } catch (e) { @@ -56,8 +63,8 @@ export const AiSpecAnalyzerModal = () => { if (!text || text.length < 40) { throw new Error('Belgeden metin çıkarılamadı. Taranmış (görüntü) PDF olabilir — metin tabanlı bir dosya deneyin.'); } - setProgress(`${usedPages}/${pageCount} sayfa okundu, model analiz ediyor…`); - const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, tender: activeTenderDetail }); + setProgress(`${usedPages}/${pageCount} sayfa okundu, ${derin ? 'derin analiz üretiliyor (1-2 dk sürebilir)…' : 'model analiz ediyor…'}`); + const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, tender: activeTenderDetail, mode: derin ? 'derin' : undefined }); if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid); confetti({ particleCount: 80, spread: 70 }); } catch (e) { @@ -133,9 +140,37 @@ export const AiSpecAnalyzerModal = () => {

Metin tabanlı .pdf veya .txt dosyası — belge tarayıcınızda okunur, yalnızca metni analiz için gönderilir.

+
+ + +
+

+ {derin + ? 'Elenme sebepleri, gözden kaçan ince detaylar, belge kontrol listesi, kritik tarihler ve teklife kadar adım adım yol haritası üretilir. Daha uzun sürer.' + : 'Yalnızca özet, yeterlik kriterleri, riskler ve teklif aralığı üretilir. Daha hızlıdır.'} +

+
@@ -151,7 +186,7 @@ export const AiSpecAnalyzerModal = () => { {activeTenderDetail && ( + + {acik && ( +
+ + {/* Sizden beklenen bilgiler */} + {eksikler.length > 0 && ( +
+ +
+

Sizden beklenen bilgiler

+

+ Belgeleri hazırlayabilmek için şunlar gerekli:{' '} + {eksikler.map((a) => a.l).join(', ')}. + Bir kez girdiğinizde bu tarayıcıda saklanır, sonraki ihalelerde tekrar sorulmaz. +

+
+
+ )} + + {/* Firma bilgileri */} +
+
İstekli (firma) bilgileri
+
+ {ALANLAR.map((a) => { + const eksik = a.zorunlu && !String(firma[a.k] || '').trim(); + return ( + + ); + })} +
+
+ + {/* Teklif ayarları */} +
+ + + +
+ + {/* Fiyatlandırma */} + {teklifTuru === 'goturu' ? ( + + ) : kalemler.length > 0 ? ( +
+
Kalem fiyatlandırma
+
+ + + + + + + + + + + {satirlar.map((r, i) => { + const { miktar, birim } = miktarAyristir(r.quantity); + const tutar = (miktar ?? 0) * (Number(r.birimFiyat) || 0); + return ( + + + + + + + ); + })} + +
KalemMiktarBirim fiyatTutar
{r.name || '—'} + {miktar ?? '—'} {birim} + + + setFiyatlar((o) => ({ ...o, [i]: e.target.value.replace(/[^\d.]/g, '') }))} + placeholder={r.estimatedUnitPrice != null ? String(r.estimatedUnitPrice) : '0'} + className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300" + /> + + {tutar ? paraTR(tutar) : '—'} +
+
+ {kalemler.some((k) => k.estimatedUnitPrice != null) && ( +

+ + Gri yazılı değerler şartnamede geçen tahmini birim fiyatlardır — teklifiniz değildir. +

+ )} +
+ ) : ( +

+ Analizde kalem dökümü çıkmadı. Götürü bedel seçip toplam teklifinizi doğrudan girebilirsiniz. +

+ )} + + {/* Toplam */} +
+
+ Teklif toplamı (KDV hariç) + {paraTR(toplam)} TL +
+ {toplam > 0 && ( +

Yazı ile: {yaziylaTL(toplam)}

+ )} +
+ + {hata && ( +
+ +

{hata}

+
+ )} + + {/* Belgeler */} +
+
Hazırlanacak belgeler
+
+ {[ + { t: 'mektup', ad: 'Teklif Mektubu', ikon: FileSignature, aciklama: 'Rakam ve yazı ile bedel' }, + { t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${kalemler.length} kalem` }, + { t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${(results?.documentChecklist || []).length} belge` }, + ].map((b) => ( + + ))} +
+ {!hazir && ( +

+ {eksikler.length > 0 + ? 'Yukarıdaki zorunlu alanları doldurun.' + : 'Belgeleri hazırlamak için teklif tutarını girin.'} +

+ )} +
+ +

+ Belgeler taslaktır ve idarenin standart formlarının yerine geçmez. İmzalamadan önce + ihale dokümanındaki form metinleriyle karşılaştırın. +

+
+ )} +
+ ); +}; diff --git a/src/components/DeepAnalysisReport.jsx b/src/components/DeepAnalysisReport.jsx new file mode 100644 index 0000000..ba0af17 --- /dev/null +++ b/src/components/DeepAnalysisReport.jsx @@ -0,0 +1,328 @@ +import React, { useState } from 'react'; +import { + AlertOctagon, AlertTriangle, Eye, CalendarClock, ClipboardList, Route, + HelpCircle, Coins, Target, CheckSquare, Square, Package, +} from 'lucide-react'; +import { BidPackageBuilder } from './BidPackageBuilder'; + +/** + * Derin analiz raporu — /api/ai/analyze mode='derin' çıktısını gösterir. + * Standart analizden farkı: elenme sebepleri, gözden kaçan ince detaylar, + * belge kontrol listesi, süreler ve teklife kadarki yol haritası. + * + * Model alanları kısmi doldurabilir; her bölüm kendi verisi varsa çizilir. + */ + +const SEVIYE_RENK = { + Kritik: 'bg-red-100 text-red-800 border-red-200', + Yüksek: 'bg-orange-100 text-orange-800 border-orange-200', + Orta: 'bg-amber-100 text-amber-800 border-amber-200', + Düşük: 'bg-slate-200 text-slate-700 border-slate-300', +}; + +const FAZ_RENK = { + Hazırlık: 'bg-slate-700', + 'Belge Toplama': 'bg-blue-600', + Teknik: 'bg-indigo-600', + Fiyatlandırma: 'bg-emerald-600', + Teklif: 'bg-amber-600', + Sözleşme: 'bg-purple-600', +}; + +const dolu = (x) => Array.isArray(x) && x.length > 0; + +const tl = (n) => + typeof n === 'number' && isFinite(n) + ? n.toLocaleString('tr-TR', { maximumFractionDigits: 2 }) + ' ₺' + : null; + +const Baslik = ({ icon: Icon, renk, children, not }) => ( +

+ + {children} + {not && {not}} +

+); + +export const DeepAnalysisReport = ({ results, tender }) => { + const [isaretli, setIsaretli] = useState({}); + if (!results) return null; + + const { + scopeOfWork, disqualificationRisks, criticalDetails, documentChecklist, + deadlines, costDrivers, roadmap, questionsForAdministration, itemsAndSpecs, + } = results; + + const varMi = scopeOfWork || dolu(disqualificationRisks) || dolu(criticalDetails) || + dolu(documentChecklist) || dolu(deadlines) || dolu(costDrivers) || + dolu(roadmap) || dolu(questionsForAdministration) || dolu(itemsAndSpecs); + if (!varMi) return null; + + const degistir = (k) => setIsaretli((o) => ({ ...o, [k]: !o[k] })); + + return ( +
+ + {/* İşin kapsamı */} + {scopeOfWork && ( +
+ İşin Kapsamı +

{scopeOfWork}

+
+ )} + + {/* UYARI — teklifi geçersiz kılan noktalar */} + {dolu(disqualificationRisks) && ( +
+
+
+ +
+
+

Teklifinizi Geçersiz Kılabilecek Noktalar

+

Bu maddelerden biri eksikse teklif değerlendirme dışı bırakılabilir.

+
+
+
    + {disqualificationRisks.map((r, i) => ( +
  • + + {r} +
  • + ))} +
+
+ )} + + {/* Gözden kaçan ince detaylar */} + {dolu(criticalDetails) && ( +
+ + Gözden Kaçmaması Gereken İnce Detaylar + +
+ {criticalDetails.map((d, i) => ( +
+
+ {d.title || 'Detay'} +
+ {d.where && ( + + {d.where} + + )} + + {d.level || 'Orta'} + +
+
+ {d.detail &&

{d.detail}

} + {d.whyItMatters && ( +

+ Neden önemli: {d.whyItMatters} +

+ )} +
+ ))} +
+
+ )} + + {/* Alınacak ürün/hizmet: evsaf, miktar, tahmini fiyat */} + {dolu(itemsAndSpecs) && ( +
+ + Alınacak Kalemler & Aranan Evsaflar + +
+ {itemsAndSpecs.map((k, i) => ( +
+
+ {k.name || '—'} +
+ {k.quantity && ( + + {k.quantity} + + )} + {tl(k.estimatedUnitPrice) && ( + + birim {tl(k.estimatedUnitPrice)} + + )} + {tl(k.estimatedTotal) && ( + + toplam {tl(k.estimatedTotal)} + + )} +
+
+ {dolu(k.keySpecs) && ( +
    + {k.keySpecs.map((e, j) => ( +
  • + + {e} +
  • + ))} +
+ )} + {k.note &&

{k.note}

} +
+ ))} +
+

+ Fiyatlar yalnızca şartnamede/ilanda geçiyorsa gösterilir; model tahmin üretmez. +

+
+ )} + +
+ {/* Belge kontrol listesi */} + {dolu(documentChecklist) && ( +
+ + Belge Kontrol Listesi + +
    + {documentChecklist.map((b, i) => { + const k = `b${i}`; + const secili = !!isaretli[k]; + return ( +
  • + +
  • + ); + })} +
+
+ )} + + {/* Süreler + maliyet sürücüleri */} +
+ {dolu(deadlines) && ( +
+ Kritik Tarihler & Süreler +
+ {deadlines.map((d, i) => ( +
+
+ {d.label || '—'} + {d.value || '—'} +
+ {d.note &&

{d.note}

} +
+ ))} +
+
+ )} + + {dolu(costDrivers) && ( +
+ Maliyeti Belirleyen Kalemler +
    + {costDrivers.map((c, i) => ( +
  • + + {c} +
  • + ))} +
+
+ )} +
+
+ + {/* Yol haritası */} + {dolu(roadmap) && ( +
+ + Teklife Kadar Yol Haritası + +
    + {roadmap.map((a, i) => { + const k = `y${i}`; + const secili = !!isaretli[k]; + return ( +
  1. +
    + + {i < roadmap.length - 1 &&
    } +
    +
    +
    + + {a.action || '—'} + + {a.phase && ( + + {a.phase} + + )} +
    +
    + {a.owner && Kim: {a.owner}} + {a.timing && Ne zaman: {a.timing}} + {a.output && Çıktı: {a.output}} +
    +
    +
  2. + ); + })} +
+
+ )} + + {/* İdareye sorulacaklar */} + {dolu(questionsForAdministration) && ( +
+ İdareye Sorulması Gereken Belirsizlikler +
    + {questionsForAdministration.map((q, i) => ( +
  • + {i + 1}. + {q} +
  • + ))} +
+

+ Bu sorular için şartnamede belirtilen süre içinde idareye açıklama talebi / zeyilname + başvurusu yapabilirsiniz. +

+
+ )} + + {/* Analiz ve yol haritası okunduktan sonraki adım: teklif zarfını hazırla */} + + +
+ ); +}; diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx index 5a7ca9a..bf56e73 100644 --- a/src/components/TenderDocuments.jsx +++ b/src/components/TenderDocuments.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useApp } from '../context/AppContext'; import { getToken } from '../services/auth'; -import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download, Upload, Trash2, Paperclip } from 'lucide-react'; +import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download, Upload, Trash2, Paperclip, Route } from 'lucide-react'; import { uploadDocument, listTenderDocuments, deleteDocument } from '../services/account'; import { extractText } from '../services/pdfText'; @@ -15,6 +15,7 @@ export const TenderDocuments = ({ tender }) => { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [bilgi, setBilgi] = useState(null); + const [ilerleme, setIlerleme] = useState(''); const ikn = tender?.ikn; const [belgeler, setBelgeler] = useState([]); @@ -47,19 +48,41 @@ export const TenderDocuments = ({ tender }) => { try { await deleteDocument(id); belgeleriYukle(); } catch (err) { setError(err.message); } }; - // Yüklenen PDF'i AI'ya ver - const belgeyiAnalizEt = async (b) => { - setBusy('belge'); setError(null); - try { - const r = await fetch(`/api/me/documents/download?id=${encodeURIComponent(b.id)}`, { + // PDF/metin tarayıcıda (pdfjs) okunur; docx/xlsx/zip için tarayıcıda çözücü + // yok, sunucudaki çıkarıcıya sorulur. .doc ve .rar'ın karşılığı yok. + const tarayicidaOkunur = (ad) => /\.(pdf|txt|md)$/i.test(String(ad || '')); + const sunucudaOkunur = (ad) => /\.(docx|xlsx|zip)$/i.test(String(ad || '')); + const okunabilirMi = (ad) => tarayicidaOkunur(ad) || sunucudaOkunur(ad); + + const belgeMetni = async (b) => { + if (sunucudaOkunur(b.ad)) { + const r = await fetch(`/api/me/documents/text?id=${encodeURIComponent(b.id)}`, { headers: { Authorization: `Bearer ${getToken()}` }, }); - if (!r.ok) throw new Error('Belge indirilemedi'); - const blob = await r.blob(); - const metin = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' })); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'metin çıkarılamadı'); + return d.metin; + } + const r = await fetch(`/api/me/documents/download?id=${encodeURIComponent(b.id)}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!r.ok) throw new Error('belge indirilemedi'); + const blob = await r.blob(); + // extractText { text, pageCount, usedPages } döndürür — nesneyi metin sanıp + // göndermek analizi sessizce bozuyordu. + const { text } = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' })); + return text; + }; + + // Yüklenen PDF'i AI'ya ver + const belgeyiAnalizEt = async (b, mod) => { + setBusy('belge'); setError(null); + try { + if (!okunabilirMi(b.ad)) throw new Error('Bu dosya türünden metin çıkarılamıyor (PDF, DOCX, XLSX, ZIP ve TXT destekleniyor).'); + const metin = await belgeMetni(b); if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).'); setIsAiSpecAnalyzerOpen(true); - await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, tender }); + await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, tender, mode: mod }); } catch (e) { setError(e.message); } finally { setBusy(null); } @@ -79,14 +102,19 @@ export const TenderDocuments = ({ tender }) => { } finally { setBusy(null); } }; + const ilanMetniGetir = async () => { + const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + const d = await r.json(); + if (!d.success) throw new Error(d.error); + return d; + }; + const ilaniAnalizEt = async () => { setBusy('analiz'); setError(null); try { - const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, { - headers: { Authorization: `Bearer ${getToken()}` }, - }); - const d = await r.json(); - if (!d.success) throw new Error(d.error); + const d = await ilanMetniGetir(); if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.'); setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi }); setIsAiSpecAnalyzerOpen(true); @@ -96,6 +124,63 @@ export const TenderDocuments = ({ tender }) => { } finally { setBusy(null); } }; + /** + * Derin analiz — teknik şartname ve diğer evrakın tamamını TEK metinde + * birleştirip gönderir: EKAP ilan metni + bu ihaleye yüklenmiş her belge. + * Böylece model şartnameyi ilanla ve eklerle çapraz okuyabiliyor. + */ + const tumEvrakiTopla = async () => { + const parcalar = []; + const atlanan = []; + + if (ekapIhalesi) { + setIlerleme('EKAP ilan metni alınıyor…'); + try { + const d = await ilanMetniGetir(); + if (d.metin && d.metin.length >= 100) { + parcalar.push(`===== EKAP İLAN METNİ (${d.ikn}) =====\n${d.metin}`); + } + } catch { + atlanan.push('EKAP ilan metni (alınamadı)'); + } + } + + for (const b of belgeler) { + if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); continue; } + setIlerleme(`${b.ad} okunuyor…`); + try { + const t = await belgeMetni(b); + if (t && t.length >= 200) parcalar.push(`===== BELGE: ${b.ad} =====\n${t}`); + else atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`); + } catch (e) { + atlanan.push(`${b.ad} (${e.message})`); + } + } + + return { metin: parcalar.join('\n\n'), parcaSayisi: parcalar.length, atlanan }; + }; + + const derinAnalizEt = async () => { + setBusy('derin'); setError(null); setBilgi(null); + try { + const { metin, parcaSayisi, atlanan } = await tumEvrakiTopla(); + if (!metin || metin.length < 200) { + throw new Error('Derin analiz için yeterli metin toplanamadı. EKAP dokümanını indirip PDF olarak yükleyin.'); + } + setBilgi({ derin: { parcaSayisi, karakter: metin.length, atlanan } }); + setIlerleme(''); + setIsAiSpecAnalyzerOpen(true); + await analyzeSpecPdfWithGemini({ + text: metin, + fileName: `${ikn || 'ihale'} — ${parcaSayisi} evrak birlikte`, + tender, + mode: 'derin', + }); + } catch (e) { + setError(e.message); + } finally { setBusy(null); setIlerleme(''); } + }; + return (

@@ -140,7 +225,7 @@ export const TenderDocuments = ({ tender }) => {

- {bilgi && ( + {bilgi && !bilgi.derin && (

{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. } {bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi. } @@ -173,7 +258,7 @@ export const TenderDocuments = ({ tender }) => { ref={dosyaRef} type="file" onChange={dosyaSec} - accept=".pdf,.doc,.docx,.zip,.rar,.txt" + accept=".pdf,.docx,.xlsx,.zip,.txt" className="hidden" /> - PDF, Word, ZIP · en fazla 10 MB + PDF, DOCX, XLSX, ZIP · en fazla 10 MB @@ -207,6 +292,14 @@ export const TenderDocuments = ({ tender }) => { > {busy === 'belge' ? '…' : 'AI analiz'} + @@ -215,6 +308,54 @@ export const TenderDocuments = ({ tender }) => { )} + {(ekapIhalesi || belgeler.length > 0) && ( +

+
+
+ +
+
+
Derin Analiz & Yol Haritası
+

+ EKAP ilan metni ve bu ihaleye yüklediğiniz tüm evrak birlikte okunur; + elenme sebepleri, gözden kaçan ince detaylar, belge kontrol listesi, kritik tarihler ve + teklife kadar adım adım yol haritası çıkarılır. +

+
+
+ + + + {bilgi?.derin ? ( +
+

+ {bilgi.derin.parcaSayisi} kaynak · {bilgi.derin.karakter.toLocaleString('tr-TR')} karakter analize gönderildi. +

+ {bilgi.derin.atlanan.length > 0 && ( +

+ Atlanan: {bilgi.derin.atlanan.join(', ')} +

+ )} +
+ ) : ( +

+ {belgeler.length > 0 + ? `${ekapIhalesi ? 'İlan metni + ' : ''}${belgeler.length} yüklü belge okunacak` + : 'Henüz belge yüklemediniz — yalnızca ilan metni okunacak'} +

+ )} +
+ )} + ); }; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index d20e799..86ee98a 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -196,6 +196,9 @@ export const AppProvider = ({ children }) => { const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); const [aiAnalysisResults, setAiAnalysisResults] = useState(null); + // Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da + // tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal. + const [aiAnalyzing, setAiAnalyzing] = useState(false); // Enterprise Module States // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (localStorage) @@ -335,11 +338,16 @@ export const AppProvider = ({ children }) => { // Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde // seçilen model zinciri (fallback) ile. - const analyzeSpecPdfWithGemini = async ({ text, fileName, tender } = {}) => { + // mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir + const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode } = {}) => { + setAiAnalyzing(true); + setAiAnalysisResults(null); + try { const { analyzeSpecText } = await import('../services/ai'); - const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain }); + const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode }); const result = { ...data.result, + mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'), fileName, model: `${data.model.label} (${data.model.provider})`, modelId: data.model.model, @@ -349,6 +357,9 @@ export const AppProvider = ({ children }) => { }; setAiAnalysisResults(result); return result; + } finally { + setAiAnalyzing(false); + } }; const [userProfile, setUserProfile] = useState({ @@ -537,6 +548,7 @@ export const AppProvider = ({ children }) => { setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, aiAnalysisResults, + aiAnalyzing, competitorWatchlist, setCompetitorWatchlist, vaultDocuments, diff --git a/src/services/ai.js b/src/services/ai.js index 51a79a3..fa7a9fa 100644 --- a/src/services/ai.js +++ b/src/services/ai.js @@ -13,11 +13,11 @@ export async function getAiConfig() { } } -export async function analyzeSpecText({ text, fileName, tender, chain }) { +export async function analyzeSpecText({ text, fileName, tender, chain, mode }) { const res = await fetch('/api/ai/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, - body: JSON.stringify({ text, fileName, tender, chain }), + body: JSON.stringify({ text, fileName, tender, chain, mode }), }); const data = await res.json().catch(() => ({})); if (!data.success) throw new Error(data.error || `Analiz başarısız (HTTP ${res.status})`); diff --git a/src/services/bidDocs.js b/src/services/bidDocs.js new file mode 100644 index 0000000..9332007 --- /dev/null +++ b/src/services/bidDocs.js @@ -0,0 +1,219 @@ +/** + * İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve + * evrak kontrol listesi. + * + * NEDEN HTML/YAZDIR: jsPDF'in gömülü fontları WinAnsi; ş/ğ/ı/İ taşımıyor + * (bkz. services/pdf.js, başlıkları ASCII'ye düşürüyor). Resmî teklif + * mektubunda bozuk Türkçe kabul edilemez, o yüzden belgeler tam Türkçe HTML + * olarak üretilip tarayıcının yazdır → "PDF olarak kaydet" akışına veriliyor. + */ + +const BIRLER = ['', 'bir', 'iki', 'üç', 'dört', 'beş', 'altı', 'yedi', 'sekiz', 'dokuz']; +const ONLAR = ['', 'on', 'yirmi', 'otuz', 'kırk', 'elli', 'altmış', 'yetmiş', 'seksen', 'doksan']; +const BASAMAK = ['', 'bin', 'milyon', 'milyar', 'trilyon']; + +function ucBasamak(n) { + const y = Math.floor(n / 100), o = Math.floor((n % 100) / 10), b = n % 10; + let s = ''; + if (y) s += y === 1 ? 'yüz' : BIRLER[y] + 'yüz'; + if (o) s += ONLAR[o]; + if (b) s += BIRLER[b]; + return s; +} + +/** 1850000.5 → "birmilyonsekizyüzellibin Türk Lirası elli Kuruş" */ +export function yaziylaTL(n) { + const sayi = Number(n); + if (!isFinite(sayi)) return ''; + const negatif = sayi < 0; + const mutlak = Math.abs(sayi); + const tam = Math.floor(mutlak); + const kurus = Math.round((mutlak - tam) * 100); + + const gruplar = []; + let kalan = tam; + while (kalan > 0) { gruplar.push(kalan % 1000); kalan = Math.floor(kalan / 1000); } + + let lira = ''; + for (let i = gruplar.length - 1; i >= 0; i--) { + const g = gruplar[i]; + if (!g) continue; + // "birbin" değil "bin" + lira += (i === 1 && g === 1) ? 'bin' : ucBasamak(g) + BASAMAK[i]; + } + if (!lira) lira = 'sıfır'; + + let s = `${lira} Türk Lirası`; + if (kurus > 0) s += ` ${ucBasamak(kurus)} Kuruş`; + return (negatif ? 'eksi ' : '') + s; +} + +export const paraTR = (n) => + (Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +/** "3 adet" → { miktar: 3, birim: "adet" } ; okunamazsa miktar null */ +export function miktarAyristir(q) { + const m = /(-?[\d.,]+)\s*(.*)$/.exec(String(q || '').trim()); + if (!m) return { miktar: null, birim: String(q || '').trim() }; + const sayi = parseFloat(m[1].replace(/\./g, '').replace(',', '.')); + return { miktar: isFinite(sayi) ? sayi : null, birim: (m[2] || '').trim() }; +} + +const kacir = (s) => + String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +const STIL = ` + @page { size: A4; margin: 20mm 18mm; } + * { box-sizing: border-box; } + body { font-family: "Times New Roman", Georgia, serif; font-size: 11.5pt; line-height: 1.55; color: #000; } + h1 { font-size: 13pt; text-align: center; margin: 0 0 4mm; letter-spacing: .3px; } + h2 { font-size: 11pt; margin: 6mm 0 2mm; } + .ust { font-size: 10pt; margin-bottom: 6mm; } + .ust div { margin: .6mm 0; } + table { width: 100%; border-collapse: collapse; margin: 3mm 0; font-size: 9.5pt; } + th, td { border: 1px solid #444; padding: 1.6mm 2mm; text-align: left; vertical-align: top; } + th { background: #eee; font-weight: bold; } + td.s, th.s { text-align: right; white-space: nowrap; } + .toplam td { font-weight: bold; background: #f4f4f4; } + .imza { margin-top: 12mm; width: 70mm; margin-left: auto; text-align: center; font-size: 10pt; } + .imza .kutu { border: 1px solid #666; height: 26mm; margin-bottom: 2mm; } + .not { font-size: 8.5pt; color: #555; margin-top: 8mm; } + ul { margin: 2mm 0; padding-left: 6mm; } + li { margin: 1.2mm 0; } + .kutucuk { display: inline-block; width: 3.6mm; height: 3.6mm; border: 1px solid #333; margin-right: 2mm; vertical-align: -0.4mm; } + @media print { .yazdirma { display: none; } } +`; + +/** Belgeyi kendi penceresinde açıp yazdırma diyaloğunu tetikler. */ +export function belgeYazdir(baslik, govde) { + const w = window.open('', '_blank', 'width=900,height=1000'); + if (!w) throw new Error('Tarayıcı yeni pencereyi engelledi. Açılır pencere iznini verip tekrar deneyin.'); + w.document.write(` + ${kacir(baslik)}${govde} +
+ +
`); + w.document.close(); + w.focus(); + return w; +} + +function ustBilgi(f) { + return `
+
İdarenin adı: ${kacir(f.idare)}
+
İşin adı: ${kacir(f.isinAdi)}
+
İhale kayıt numarası: ${kacir(f.ikn)}
+
İhale tarihi: ${kacir(f.ihaleTarihi || '—')}
+
`; +} + +/** KİK birim fiyat / götürü bedel teklif mektubu metni. */ +export function teklifMektubuHtml(f, toplam) { + const goturu = f.teklifTuru === 'goturu'; + const tur = goturu ? 'GÖTÜRÜ BEDEL TEKLİF MEKTUBU' : 'BİRİM FİYAT TEKLİF MEKTUBU'; + const bedelCumle = goturu + ? 'işin tamamını teklif ettiğimiz toplam bedel karşılığında yapmayı' + : 'işi, teklif birim fiyat cetvelinde belirttiğimiz birim fiyatlar üzerinden yapmayı'; + + return `

${tur}

+${ustBilgi(f)} +

Yukarıda ihale kayıt numarası ve adı yer alan ihaleye ilişkin ihale dokümanını +oluşturan bütün belgeler tarafımızdan incelenmiş, okunmuş ve herhangi bir ayrım ve +sınırlama yapılmadan bütün koşullarıyla kabul edilmiştir.

+ +

İhale dokümanında yer alan bütün şartları kabul ederek, söz konusu ${bedelCumle} +kabul ve taahhüt ediyoruz.

+ +

Teklifimizin, ihale tarihinden itibaren ${kacir(f.gecerlilikGun || '60')} +takvim günü süreyle geçerli olduğunu beyan ediyoruz.

+ +${f.kisim ? `

Teklifimiz ${kacir(f.kisim)} kısmına/kısımlarına ilişkindir.

` : ''} + +

Teklif ettiğimiz toplam bedel:

+ + + +
Rakam ile${paraTR(toplam)} TL
Yazı ile${kacir(yaziylaTL(toplam))}
+

(Teklif edilen bedele KDV dâhil değildir.)

+ +

İstekliye ait bilgiler

+ + + + + + +
Adı / Ticaret unvanı${kacir(f.firmaUnvan)}
Tebligata esas adres${kacir(f.adres)}
Vergi dairesi / numarası${kacir(f.vergiDairesi)} / ${kacir(f.vergiNo)}
Ticaret sicil / MERSİS no${kacir(f.sicilNo)}
Telefon / E-posta${kacir(f.telefon)} · ${kacir(f.eposta)}
+ +
+
+
${kacir(f.yetkiliAd)}
+
${kacir(f.yetkiliGorev)}
+
Kaşe ve İmza
+
+

Bu taslak Ihaleist tarafından hazırlanmıştır. İmzalamadan önce idarenin +ihale dokümanındaki standart form metniyle karşılaştırınız.

`; +} + +/** Birim fiyat teklif cetveli. */ +export function teklifCetveliHtml(f, satirlar, toplam) { + const govde = satirlar.map((r, i) => { + const { miktar, birim } = miktarAyristir(r.quantity); + const bf = Number(r.birimFiyat) || 0; + const tutar = (miktar ?? 0) * bf; + return ` + ${i + 1} + ${kacir(r.name)} + ${miktar ?? kacir(r.quantity || '—')} + ${kacir(birim)} + ${bf ? paraTR(bf) : '—'} + ${tutar ? paraTR(tutar) : '—'} + `; + }).join(''); + + return `

BİRİM FİYAT TEKLİF CETVELİ

+${ustBilgi(f)} + + + + + + + ${govde} + + + + +
SıraMal / hizmet adıMiktarBirimBirim fiyat (TL)Tutar (TL)
Toplam tutar (KDV hariç)${paraTR(toplam)}
+

Yazı ile: ${kacir(yaziylaTL(toplam))}

+
+
+
${kacir(f.firmaUnvan)}
+
${kacir(f.yetkiliAd)} · ${kacir(f.yetkiliGorev)}
+
Kaşe ve İmza
+
`; +} + +/** Teklif zarfına girecek evrakın kontrol listesi / dosya kapağı. */ +export function evrakListesiHtml(f, belgeler) { + const satirlar = belgeler.map((b) => `
  • + ${kacir(b.document)} + ${b.mandatory === false ? ' (isteğe bağlı)' : ''} + ${b.note ? `
    ${kacir(b.note)}
    ` : ''} +
  • `).join(''); + + return `

    TEKLİF DOSYASI EVRAK KONTROL LİSTESİ

    +${ustBilgi(f)} +
    İstekli: ${kacir(f.firmaUnvan)}
    +

    Zarfa konulacak belgeler

    +
      ${satirlar || '
    • Analizden belge listesi çıkarılamadı.
    • '}
    +
    +
    +
    Hazırlayan: ${kacir(f.yetkiliAd)}
    +
    Tarih / İmza
    +
    +

    Liste, yüklediğiniz ihale dokümanının yapay zekâ analizinden üretilmiştir; +zarfı kapatmadan önce idari şartnamenin yeterlik maddeleriyle karşılaştırınız.

    `; +}