import { analyzeInParts } from './analysis-parts.mjs'; import { qualifyAnalysis } from './analysis-quality.mjs'; /** * 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ı. Kaynak metinde bulunmayan hukuki sonuç çıkartma. Konsorsiyum ile iş ortaklığı farklı kavramlardır; birinin yasağını diğerine genişletme. Bir katsayıyı yaklaşık maliyet yüzdesi olarak yorumlama; formül metinde yoksa hesaplama yapma. Her kritik koşulun madde numarasını ve metindeki dayanağını belirt. Bölümler arasında bulundu/bulunamadı çelişkisi oluşturma. Ö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 madde olsun. itemsAndSpecs içinde kaynakta bulunan kalemleri eksiltme veya birleştirme; sığmayan listeyi tamamlanmış gibi sunma. - 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ı'); const govde = s.slice(start, end + 1); try { return JSON.parse(govde); } catch (e) { const onarilmis = kesikJsonOnar(s.slice(start)); if (onarilmis) return {...onarilmis,_outputRepaired:true}; throw e; } } /** * Model çıktısı max_tokens'ta kesildiğinde JSON yarım kalır. Sondan geriye * doğru güvenli kesme noktaları denenir; açık kalan dizi/nesneler kapatılır. */ function kesikJsonOnar(s) { for (let cut = s.length; cut > 50; ) { cut = Math.max(s.lastIndexOf('}', cut - 1), s.lastIndexOf(']', cut - 1), s.lastIndexOf('"', cut - 1)); if (cut <= 0) return null; let parca = s.slice(0, cut + 1).replace(/,\s*$/, ''); // yığın: string dışındaki açık parantezler const yigin = []; let strIcinde = false, kacis = false; for (const ch of parca) { if (strIcinde) { if (kacis) kacis = false; else if (ch === '\\') kacis = true; else if (ch === '"') strIcinde = false; continue; } if (ch === '"') strIcinde = true; else if (ch === '{' || ch === '[') yigin.push(ch); else if (ch === '}' || ch === ']') yigin.pop(); } if (strIcinde) { cut -= 1; continue; } parca = parca.replace(/,\s*$/, ''); // "anahtar": ile biten yarım çift -> anahtarı at parca = parca.replace(/,?\s*"[^"]*"\s*:\s*$/, ''); while (yigin.length) parca += yigin.pop() === '{' ? '}' : ']'; try { return JSON.parse(parca); } catch { cut -= 1; } } return null; } /** Zinciri sırayla dener; ilk başarılı sonucu döndürür. */ export async function analyzeSpec({ text, fileName, tender, chain, systemOverride, mode, extraction, signal, _singlePart = false }) { signal?.throwIfAborted(); if (!_singlePart && String(text || '').length > 24000) { return analyzeInParts({text,fileName,tender,mode,extraction,signal}, part => analyzeSpec({ text:part.text,fileName:`${fileName || 'Doküman'} — bölüm ${part.index+1}`, tender:tender ? {...tender,items:[]} : undefined,chain,mode,signal,_singlePart:true, systemOverride:(systemOverride || (mode === 'derin' ? DERIN_SYSTEM_PROMPT : SYSTEM_PROMPT)) + '\nBu metin büyük dokümanın bir bölümüdür. Yalnız bu bölümde görülen koşulları çıkar; bu bölümde bulunmayan şartın bütün dokümanda bulunmadığı sonucunu çıkarma.', })); } const derin = mode === 'derin'; // Ücretli yedek: Gemini Flash her zaman zincirin sonunda. Panelde seçilen // modeller (ör. ücretsiz/limitli olanlar) tıkanınca analiz boş kalmasın; // yalnız üsttekilerin hepsi başarısız olursa devreye girer. const YEDEK = 'gemini'; const secim = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude']) .filter((id) => PROVIDERS[id]); const order = secim.includes(YEDEK) ? secim : [...secim, YEDEK]; // 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)) + '\nYanıta citations dizisi ekle: her kritik bulgu, kalem ve belge için {"path":"itemsAndSpecs.0","quote":"kaynak metinden aynen alınan 12-1500 karakterlik dayanak"}. path ilgili çıktı dizisinin sıfır tabanlı indeksidir. Alıntı metnini değiştirme, sayfa veya madde uydurma. Kaynakta dayanak yoksa alıntı üretme.'; const maxTokens = derin ? 12000 : 4000; const timeoutMs = derin ? 150000 : 90000; const attempts = []; for (const id of order) { signal?.throwIfAborted(); 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 cancel = () => ac.abort(); signal?.addEventListener('abort',cancel,{once:true}); 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); } signal?.throwIfAborted(); const parsed = extractJson(raw); const result = qualifyAnalysis(parsed,{text,fileName,extraction,limit:derin ? 90000 : 60000, expectedItems:tender?.items?.length || 0,repaired:parsed._outputRepaired === true}); 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) { signal?.throwIfAborted(); attempts.push({ id, ok: false, yol: route.yol, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message }); } finally { clearTimeout(timer);signal?.removeEventListener('abort',cancel); } } 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; }