1) Bildirim artık KONU odaklı (il değil)
- Kullanıcı hangi konularda ihale alabildiğini seçer; varsayılan tüm Türkiye
- İl/alım türü/favori kurum 'daha da daralt' altında isteğe bağlı
- notify-store: konular kolonu + otomatik şema geçişi
- notifier: konu başlıkları ihale başlığı/sektör/branş/kalemlerde aranır
2) Gerçek firma sitesi tarama (webScraperService mock'unun yerine)
- server/site-scan.mjs: siteyi GERÇEKTEN çeker, ana sayfa + ürün/hizmet/
hakkımızda iç sayfalarını tarar, HTML'i metne çevirir, AI ile firmanın
tedarik edebildiği ihale konularını çıkarır ('uydurma' yasaklı prompt)
- /api/company/scan (auth'lu)
- ai.mjs: systemOverride desteği (farklı görevler için ayrı sistem promptu)
- Arayüz: 'Sitenizi tarayıp konuları otomatik önerelim' → tıklayarak ekleme
Not: eski webScraperService.js sabit anahtar-kelime sözlüğüydü; domain adında
'tanriverdi' geçince tıbbi ürün listesi uyduruyordu.
259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|