376 lines
18 KiB
JavaScript
376 lines
18 KiB
JavaScript
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 }) {
|
||
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 parsed = extractJson(raw);
|
||
const result = qualifyAnalysis(parsed,{text,fileName,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) {
|
||
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;
|
||
}
|