Files
ihaleist/server/ai.mjs
T
hikmettan 168052c267 feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar
Roadmap'in 6 maddesi de gerçek çalışır halde:

1) Geçmiş veri motoru (EKAP v2)
   - server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
     ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
     yaklaşık maliyet, teklif sayısı), TR firma adı normalize
   - server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
   - server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
   - server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
   - /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
     rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor

2) Gerçek AI şartname analizi
   - server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
     Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
   - pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
   - AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata

3) KİK kurul kararları
   - server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
     düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
   - KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri

4) Fiyatlandırma + ödeme
   - server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
   - Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı

5) TED (AB ihaleleri)
   - server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
   - rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü

6) Kırmızı bayrak & şeffaflık
   - analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
     aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
   - RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
2026-08-28 10:37:50 +03:00

207 lines
7.8 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 — çoklu sağlayıcı + fallback zinciri.
* Bağımlılık yok (Node 18+ built-in fetch).
*
* Env (hangisi varsa o sağlayıcı kullanılabilir olur):
* ZAI_API_KEY → GLM (Z.ai)
* DEEPSEEK_API_KEY → DeepSeek
* GEMINI_API_KEY → Gemini (Google)
* XAI_API_KEY → Grok (xAI)
* OPENAI_API_KEY → GPT (OpenAI)
* ANTHROPIC_API_KEY → Claude (Anthropic)
*
* Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL=glm-5.3).
*/
export const PROVIDERS = {
glm: {
label: 'GLM', provider: 'Z.ai', envKey: 'ZAI_API_KEY',
model: () => process.env.GLM_MODEL || 'glm-5.3',
url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai',
},
deepseek: {
label: 'DeepSeek', provider: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY',
model: () => process.env.DEEPSEEK_MODEL || 'deepseek-chat',
url: 'https://api.deepseek.com/chat/completions', style: 'openai',
},
gemini: {
label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY',
model: () => process.env.GEMINI_MODEL || 'gemini-flash-latest',
style: 'gemini',
},
grok: {
label: 'Grok', provider: 'xAI', envKey: 'XAI_API_KEY',
model: () => process.env.XAI_MODEL || 'grok-4',
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.2',
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-sonnet-5',
url: 'https://api.anthropic.com/v1/messages', style: 'anthropic',
},
};
export function availableProviders() {
return Object.entries(PROVIDERS)
.filter(([, p]) => Boolean(process.env[p.envKey]))
.map(([id, p]) => ({ id, label: p.label, provider: p.provider, model: p.model() }));
}
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');
}
async function callOpenAiStyle(p, key, userPrompt, signal) {
const res = await fetch(p.url, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: p.model(),
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ 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, userPrompt, signal) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${p.model()}:generateContent?key=${encodeURIComponent(key)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
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, userPrompt, signal) {
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: p.model(),
max_tokens: 4000,
system: SYSTEM_PROMPT,
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();
// ```json ... ``` sarmalını temizle
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.
* @returns {{result:object, model:object, attempts:Array}}
*/
export async function analyzeSpec({ text, fileName, tender, chain }) {
const order = (Array.isArray(chain) && chain.length ? chain : ['glm', 'deepseek', 'gemini', 'grok', 'gpt', 'claude'])
.filter((id) => PROVIDERS[id]);
const userPrompt = buildUserPrompt({ text, fileName, tender });
const attempts = [];
for (const id of order) {
const p = PROVIDERS[id];
const key = process.env[p.envKey];
if (!key) {
attempts.push({ id, ok: false, error: `${p.envKey} tanımlı değil` });
continue;
}
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 90000);
try {
let raw;
if (p.style === 'gemini') raw = await callGemini(p, key, userPrompt, ac.signal);
else if (p.style === 'anthropic') raw = await callAnthropic(p, key, userPrompt, ac.signal);
else raw = await callOpenAiStyle(p, key, userPrompt, ac.signal);
const result = extractJson(raw);
attempts.push({ id, ok: true });
return {
result,
model: { id, label: p.label, provider: p.provider, model: p.model() },
attempts,
};
} catch (e) {
attempts.push({ id, ok: false, 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;
}