/** * 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.`; function buildUserPrompt({ text, fileName, tender }) { 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, 60000)); return parts.join('\n'); } // --- OpenRouter (OpenAI uyumlu) --- async function callOpenRouter(model, key, userPrompt, signal, sistem = SYSTEM_PROMPT) { 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: 4000, 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) { 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: 4000, }), 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) { 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: 4000, 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) { 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, 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 }) { 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; 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(), 90000); try { let raw; if (route.yol === 'openrouter') { raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem); } else if (p.style === 'gemini') { raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem); } else if (p.style === 'anthropic') { raw = await callAnthropic(p, route.key, route.model, userPrompt, ac.signal, sistem); } else { raw = await callOpenAiStyle(p, route.key, route.model, userPrompt, ac.signal, sistem); } const result = extractJson(raw); attempts.push({ id, ok: true, yol: route.yol }); return { result, 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; }