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:
2026-08-31 14:53:46 +03:00
parent be79f9366a
commit 3eb0f91823
10 changed files with 1584 additions and 51 deletions
+92 -18
View File
@@ -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,
};