Files
ihaleist/server/ai-teklif.mjs
T

131 lines
10 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.
import { auditOfferInParts } from './offer-audit-parts.mjs';
import { qualifyOfferDraft } from './offer-draft.mjs';
import { createHash } from 'node:crypto';
import { questionChunks, qualifyQuestionAnswer } from './document-question.mjs';
import { documentContent } from './document-content.mjs';
// Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut
// analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde:
// 1) belgeyeSor: ihale dokümanı/ilan metninde soru → atıflı yanıt
// 2) teklifDenetle: kullanıcının taslağı × ihale gereklilikleri → karşılanıyor/eksik/belirsiz
// 3) teklifTaslak: kapak mektubu / teknik / ticari / tam dosya bölümleri
// Sayılar (fiyat, süre, adet) yalnızca verilen veriden; model rakam uydurmaz,
// bilinmeyene [DOLDURUN] koyar.
import { analyzeSpec } from './ai.mjs';
import { ilanMetni, documentText, browserAvailable } from './documents.mjs';
import * as kalemStore from './kalem-store.mjs';
const DURAK = new Set(['ve', 'veya', 'ile', 'için', 'bir', 'bu', 'ne', 'nasıl', 'kaç', 'kadar', 'mi', 'mı', 'mu', 'mü', 'var', 'yok', 'olan', 'olarak', 'gibi', 'ihale', 'ihalede', 'şartname', 'şartnamede', 'nedir', 'midir', 'hangi']);
const norm = (s) => String(s ?? '').toLocaleLowerCase('tr-TR').replace(/[^\p{L}\p{N}\s%]/gu, ' ').replace(/\s+/g, ' ').trim();
export function terimler(soru) {
return [...new Set(norm(soru).split(' ').filter((k) => k && !DURAK.has(k) && (k.length >= 3 || /^\d+$/.test(k))))].slice(0, 12);
}
export const parcala = questionChunks;
/** Terim kapsamasına göre en iyi k parça. */
export function getir(parcalar, soru, k = 8) {
const t = terimler(soru);
if (!t.length) return [];
const ifade = norm(soru);
return parcalar
.map((icerik, i) => {
const m = norm(icerik);
let hit = 0;
for (const x of t) if (m.includes(x)) hit += /^\d+$/.test(x) ? 1.5 : 1;
const puan = hit / t.length + (ifade.length >= 8 && m.includes(ifade) ? 0.5 : 0);
return { n: i + 1, icerik, puan };
})
.filter((x) => x.puan > 0)
.sort((a, b) => b.puan - a.puan)
.slice(0, k);
}
/** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */
export async function ihaleMetni(ikn, verilen, ilan) {
if (verilen !== undefined) {
if (typeof verilen !== 'string' || !verilen.trim()) throw new Error('Yüklenen kaynak metni boş veya geçersiz. Okunabilir bir dosya seçin.');
return {text:verilen, kaynak:'yüklenen'};
}
if (ikn && (await browserAvailable().catch(() => false))) {
try { const text = documentContent(await documentText(ikn)); if (text.length > 200) return { text, kaynak: 'EKAP dokümanı' }; } catch { /* ilan metnine düş */ }
}
if (ikn) {
try { const text = documentContent(await ilanMetni(ikn)); if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ }
}
if (String(ilan || '').trim().length > 100) return {text:String(ilan),kaynak:'İhale ilanı (teknik şartname değil)'};
return { text: '', kaynak: null };
}
export async function belgeyeSor({ ikn, soru, text, tender, chain, signal }) {
const q = String(soru ?? '').trim().slice(0, 500);
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.');
const { text: metin, kaynak } = await ihaleMetni(ikn, text, tender?.aciklama);
if (!metin) throw new Error('Bu ihale için doküman metni bulunamadı — PDF\'i "Yapay Zeka Asistanı"ndan yükleyip tekrar sorun.');
const parcalar = parcala(metin);
const kaynaklar = getir(parcalar, q, 8);
if (!kaynaklar.length) return { cevap: 'Terim aramasında bu soruyla eşleşen bir bölüm bulunamadı; bu, şartın belgede bulunmadığını kanıtlamaz; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak };
const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik}`).join('\n\n');
const sistem = `Sen Türkiye kamu ihalesi uzmanısın. Sana bir ihale dokümanının numaralı parçaları verilecek. YALNIZCA bu parçalardaki bilgiyle yanıt ver; parçalarda yoksa "Seçilen kaynak parçalarında bu bilgi bulunamadı" de ve kontrol edilen parçalara atıf ver, tahmin etme. Her iddianın sonuna dayandığı parça numarasını köşeli parantezle yaz: [1], [3]. Sayı, tarih, tutar, süre değerlerini aynen aktar. Kısa ve net Türkçe. Yalnızca JSON döndür: {"cevap":"..."}`;
const out = await analyzeSpec({ text: `PARÇALAR:\n\n${baglam}\n\nSORU: ${q}\n\nYalnızca {"cevap":"..."} JSON'u döndür.`, tender, chain, signal, systemOverride: sistem });
return {...qualifyQuestionAnswer(out?.result, kaynaklar), kaynak,
sourceReview:{sourceHash:createHash('sha256').update(metin).digest('hex'), totalChunks:parcalar.length, selectedChunks:kaynaklar.length},
kapsam:'Yanıt yalnız soruyla eşleşen kaynak parçalarına dayanır. Kaynak numarası kontrolü, yanıtın anlam bakımından doğruluğunu tek başına kanıtlamaz; diğer bölümlerde ek veya çelişen şartlar bulunabilir.'};
}
export function gereklilikMetni(tender, gereklilikler, metin, allItems = false) {
const g = [];
for (const x of Array.isArray(gereklilikler) ? gereklilikler : []) if (x) g.push(typeof x === 'string' ? x : x.belge || x.item || x.ad || JSON.stringify(x));
for (const y of Array.isArray(tender?.yeterlikKriterleri) ? tender.yeterlikKriterleri : []) if (y) g.push(String(y));
for (const i of Array.isArray(tender?.items) ? tender.items.slice(0, allItems ? undefined : 30) : []) g.push(`Kalem: ${i.ad} ${i.miktar ?? ''} ${i.birim ?? ''}`.trim());
if (tender?.ikn && kalemStore.available()) for (const k of ((kalemStore.kalemOku(tender.ikn) || {}).kalemler || []).slice(0, allItems ? undefined : 30)) g.push(`Kalem: ${k.ad} ${k.miktar ?? ''} ${k.birim ?? ''}`.trim());
if (metin) {
// Önce anahtar sözcüğü geçen SATIR (madde) alınır; bütün metni yapıştırmak okunmaz oluyordu.
const satirlar = String(metin).split(/\r?\n+/).map((x) => x.trim()).filter((x) => x.length > 15);
for (const s of ['teminat', 'teslim süresi', 'geçerlilik', 'ceza', 'numune', 'belge']) {
const satir = satirlar.find((x) => x.toLocaleLowerCase('tr-TR').includes(s));
if (satir) { g.push(`${s}: ${satir.slice(0, 300).replace(/\s+/g, ' ')}`); continue; }
const p = getir(parcala(metin), s, 1)[0];
if (p) g.push(`${s}: ${p.icerik.slice(0, 300).replace(/\s+/g, ' ')}`);
}
}
return [...new Set(g)].slice(0, allItems ? undefined : 60);
}
export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text, signal }) {
const t = String(taslak ?? '').trim();
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.');
const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama);
const liste = gereklilikMetni(tender, gereklilikler, metin, true);
if (!liste.length) throw new Error('Gereklilik listesi yok — önce derin analiz çalıştırın veya dokümanı yükleyin.');
const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür; gereklilik alanına verilen gereklilik metnini değiştirmeden aynen kopyala.`;
return auditOfferInParts(liste,t,async (group,draftPart) => {
const out = await analyzeSpec({text:`GEREKLİLİKLER:\n${group.map((g,i)=>`${i+1}. ${g}`).join('\n')}\n\nTEKLİF TASLAĞI BÖLÜMÜ:\n${draftPart}\n\nYalnızca JSON döndür.`,
tender:tender ? {...tender,items:[]} : undefined,chain,systemOverride:sistem,mode:'derin',signal,_singlePart:true});
return out?.result;
},{signal});
}
const BOLUMLER = {
kapak: ['Teklif Mektubu (Kapak)'],
teknik: ['Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ekler'],
ticari: ['Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
tam: ['Teklif Mektubu (Kapak)', 'Firma Tanıtımı ve Yeterlilik', 'Teknik Teklif — Kapsam ve Yaklaşım', 'Ürün / Hizmet Uygunluğu (Şartname Maddeleriyle)', 'Teslim Planı ve Kalite Güvencesi', 'Ticari Teklif — Fiyat Özeti', 'Ödeme ve Teslim Koşulları', 'Teklif Geçerlilik Süresi ve Teminat', 'Ekler'],
};
export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text, signal }) {
const istenen = BOLUMLER[tur] || BOLUMLER.tam;
const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama);
const liste = gereklilikMetni(tender, gereklilikler, metin);
const baglam = [
`İHALE: ${tender?.baslik ?? '?'} · İdare: ${tender?.kurum ?? '?'} · İl: ${tender?.sehir ?? '?'} · Tarih: ${tender?.tarih ?? '?'} · Usul: ${tender?.usul ?? tender?.alimTuru ?? '?'}${tender?.yaklasikMaliyet ? ' · Yaklaşık maliyet: ' + tender.yaklasikMaliyet : ''}${ikn ? ' · İKN ' + ikn : ''}`,
firma ? `FİRMA: ${typeof firma === 'string' ? firma : JSON.stringify(firma).slice(0, 1500)}` : null,
liste.length ? 'GEREKLİLİKLER / KALEMLER:\n' + liste.map((g) => '- ' + g).join('\n') : null,
Array.isArray(belgeler) && belgeler.length ? 'DEPODAKİ BELGELER:\n' + belgeler.slice(0, 40).map((b) => `- ${b.ad || b}${b.kategori ? ' [' + b.kategori + ']' : ''}`).join('\n') : null,
fiyat ? `FİYAT VERİSİ: ${typeof fiyat === 'string' ? fiyat : JSON.stringify(fiyat).slice(0, 1500)}` : null,
].filter(Boolean).join('\n\n');
const sistem = `Sen Türkiye kamu ihalelerinde (4734) deneyimli bir teklif yazarısın. Verilen bilgilerle resmî, kısa ve somut bir teklif dosyası taslağı yaz. Verilmeyen bilgiyi UYDURMA: firma adı, yetkili, tarih, fiyat, süre boşluklarına [DOLDURUN: ...] yaz. Fiyat yalnızca FİYAT VERİSİ varsa yazılır. Ekler bölümünde istenen belgeleri madde madde ver. Üslup resmî; abartı yok. Yalnızca JSON: {"bolumler":[{"baslik":"","icerik":""}]} — istenen bölümleri aynı sırada ver; bölüm başlıklarını değiştirmeden aynen kopyala.`;
const out = await analyzeSpec({ text: `BAĞLAM:\n${baglam}\n\nİSTENEN BÖLÜMLER:\n${istenen.map((x, i) => `${i + 1}. ${x}`).join('\n')}\n\nYalnızca JSON döndür.`, tender, chain, signal, systemOverride: sistem, mode: 'derin' });
return qualifyOfferDraft(out?.result, istenen);
}