Files
ihaleist/server/ai.mjs
T
hikmettanandClaude Fable 5.1 782b361d24 feat: Gemini Flash ücretli yedek — analiz zincirinin sonunda her zaman denenir
Panelde seçilen modeller tıkanınca (limit/429) şartname analizi ve teklif asistanı
boş kalıyordu; analyzeSpec zincire yoksa 'gemini'yi sona ekler. Panel ve kılavuz notu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:45:04 +03:00

370 lines
17 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ı');
const govde = s.slice(start, end + 1);
try { return JSON.parse(govde); } catch (e) {
const onarilmis = kesikJsonOnar(s.slice(start));
if (onarilmis) return onarilmis;
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 }) {
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);
const maxTokens = derin ? 12000 : 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;
}