Files
ihaleist/server/ai.mjs
T
hikmettan 3eb0f91823 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
2026-08-31 14:53:46 +03:00

333 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback.
* Bağımlılık yok (Node 18+ built-in fetch).
*
* EN KOLAY KURULUM — tek anahtar, tüm modeller:
* OPENROUTER_API_KEY=sk-or-v1-...
* Bu tanımlıysa zincirdeki tüm modeller OpenRouter üzerinden çağrılır.
*
* İsteğe bağlı doğrudan sağlayıcı anahtarları (OpenRouter'ı ezer):
* ZAI_API_KEY, DEEPSEEK_API_KEY, GEMINI_API_KEY,
* XAI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY
*
* Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL, GLM_OR_MODEL).
*/
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export const PROVIDERS = {
glmflash: {
label: 'GLM 5.3 Flash', provider: 'Z.ai', envKey: 'ZAI_API_KEY',
model: () => process.env.GLM_FLASH_MODEL || 'glm-5.3-flash',
or: () => process.env.GLM_FLASH_OR_MODEL || 'z-ai/glm-5.3-flash',
url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai',
},
gemini: {
label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY',
model: () => process.env.GEMINI_MODEL || 'gemini-flash-latest',
or: () => process.env.GEMINI_OR_MODEL || '~google/gemini-flash-latest',
style: 'gemini',
},
deepseek: {
label: 'DeepSeek', provider: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY',
model: () => process.env.DEEPSEEK_MODEL || 'deepseek-chat',
or: () => process.env.DEEPSEEK_OR_MODEL || '~deepseek/deepseek-v4-flash-latest',
url: 'https://api.deepseek.com/chat/completions', style: 'openai',
},
grok: {
label: 'Grok', provider: 'xAI', envKey: 'XAI_API_KEY',
model: () => process.env.XAI_MODEL || 'grok-4.6',
or: () => process.env.XAI_OR_MODEL || '~x-ai/grok-latest',
url: 'https://api.x.ai/v1/chat/completions', style: 'openai',
},
gpt: {
label: 'GPT', provider: 'OpenAI', envKey: 'OPENAI_API_KEY',
model: () => process.env.OPENAI_MODEL || 'gpt-5.6-sol',
or: () => process.env.OPENAI_OR_MODEL || 'openai/gpt-chat-latest',
url: 'https://api.openai.com/v1/chat/completions', style: 'openai',
},
claude: {
label: 'Claude', provider: 'Anthropic', envKey: 'ANTHROPIC_API_KEY',
model: () => process.env.ANTHROPIC_MODEL || 'claude-opus-5',
or: () => process.env.ANTHROPIC_OR_MODEL || '~anthropic/claude-opus-latest',
url: 'https://api.anthropic.com/v1/messages', style: 'anthropic',
},
};
export const openRouterKey = () => process.env.OPENROUTER_API_KEY || null;
/** Bir model için hangi yol kullanılacak: doğrudan sağlayıcı mı, OpenRouter mı? */
export function routeFor(id) {
const p = PROVIDERS[id];
if (!p) return null;
if (process.env[p.envKey]) return { yol: 'direct', key: process.env[p.envKey], model: p.model() };
if (openRouterKey()) return { yol: 'openrouter', key: openRouterKey(), model: p.or() };
return null;
}
export function availableProviders() {
return Object.keys(PROVIDERS)
.map((id) => {
const r = routeFor(id);
if (!r) return null;
const p = PROVIDERS[id];
return { id, label: p.label, provider: p.provider, model: r.model, yol: r.yol };
})
.filter(Boolean);
}
export function aiConfigured() {
return availableProviders().length > 0;
}
const SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734/4735 sayılı kanunlar) konusunda uzman bir ihale danışmanısın.
Sana verilen teknik şartname / ihale ilanı metnini analiz et.
YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema:
{
"summary": "şartnamenin 2-3 cümlelik özeti",
"qualificationCriteria": ["istenen yeterlik belgeleri, madde madde"],
"criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar"],
"risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}],
"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"
}
}
Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`;
/**
* 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) {
parts.push(`İhale başlığı: ${tender.baslik || '-'}`);
parts.push(`İdare: ${tender.kurum || '-'}`);
parts.push(`Şehir: ${tender.sehir || '-'}`);
parts.push(`Usul: ${tender.usul || tender.alimTuru || '-'}`);
if (tender.yaklasikMaliyet) parts.push(`Yaklaşık maliyet: ${tender.yaklasikMaliyet}`);
if (Array.isArray(tender.items) && tender.items.length) {
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, limit));
return parts.join('\n');
}
// --- OpenRouter (OpenAI uyumlu) ---
async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT, maxTokens = 4000) {
const res = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
// OpenRouter sıralamaları için önerilen başlıklar
'HTTP-Referer': process.env.APP_URL || 'https://ihaleist.seymata.com',
'X-Title': 'Ihaleist',
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: sistem },
{ role: 'user', content: userPrompt },
],
temperature: 0.2,
max_tokens: maxTokens,
response_format: { type: 'json_object' },
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
if (data?.error) throw new Error(data.error.message || 'OpenRouter hatası');
return data?.choices?.[0]?.message?.content || '';
}
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' },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: sistem },
{ role: 'user', content: userPrompt },
],
temperature: 0.2,
max_tokens: maxTokens,
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.choices?.[0]?.message?.content || '';
}
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: sistem }] },
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: maxTokens, responseMimeType: 'application/json' },
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || '';
}
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: maxTokens,
system: sistem,
messages: [{ role: 'user', content: userPrompt }],
}),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`);
return data?.content?.map((c) => c.text || '').join('') || '';
}
function extractJson(raw) {
if (!raw) throw new Error('Model boş yanıt döndürdü');
let s = String(raw).trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) s = fence[1].trim();
const start = s.indexOf('{');
const end = s.lastIndexOf('}');
if (start === -1 || end === -1) throw new Error('Yanıtta JSON bulunamadı');
return JSON.parse(s.slice(start, end + 1));
}
/** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */
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]);
// 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) {
const p = PROVIDERS[id];
const route = routeFor(id);
if (!route) {
attempts.push({ id, ok: false, error: `anahtar yok (${p.envKey} veya OPENROUTER_API_KEY)` });
continue;
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let raw;
if (route.yol === 'openrouter') {
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, maxTokens);
} else if (p.style === 'anthropic') {
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, 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,
};
} catch (e) {
attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message });
} finally {
clearTimeout(timer);
}
}
const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', '));
err.attempts = attempts;
throw err;
}