diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index e686522..0b5e282 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -441,3 +441,13 @@ Yeni sunucu doğrulayıcısı gerekliliği tam metin eşleşmesiyle tekil olarak Ekran oranı yalnız listedeki alıntıyla desteklenen maddeler olarak açıklar; kontrol gereken madde sayısını ve resmi uygunluk/tüm doküman kabulü olmadığını gösterir. Alıntının taslakta bulunması, anlam bakımından şartın karşılandığını tek başına kanıtlamaz. Gereklilik toplamadaki mevcut örnekleme/satır sınırları ve tam doküman kapsamı hâlâ geliştirme gerektiriyor; bu değişiklik tam mevzuat/semantik doğrulama değildir. Doğrulama: uydurma kanıt, eksik madde, yeniden sıralanmış satır, yinelenmiş/değişmiş gereklilik ve bozuk yanıt için dört yeni test. Tüm 91 test ve üretim derlemesi geçti. Gerçek sağlayıcıyla uçtan uca denetim kabulü yapılmadı. Büyük paket uyarıları ve planın diğer açık işleri sürüyor. + +### D06 — Belgeye Sor kaynak kaybı ve atıf doğrulaması + +Önceki `parcala` uygulamasında uzun tek satır tek parça olarak kalıyordu. Soru bağlamı her parçanın ilk 1.400 karakteriyle sınırlandığından sonda eşleşen şart modele ulaşmayabiliyordu. Son 40 karakterden kısa parça da atılabiliyordu. Yeni sınırlı parçalayıcı tüm karakterleri, kısa son maddeleri ve Unicode karakter bütünlüğünü korur. Modele seçilen parçalar tam gönderilir. + +Model yanıtında boş/geçersiz metin, hiç atıf olmaması veya modele sunulmayan kaynak numarası varsa istek hata verir. Atıf yokken rastgele ilk üç parçayı kaynak olarak iliştirme kaldırıldı. Gösterilen kaynaklar yalnız gerçekten atıf verilen parçaların tam metnidir. Yanıtta kaynak SHA-256, toplam ve seçilen parça sayısı taşınır. Ekran seçilmiş parçaların kapsamını açıklar; eşleşme yokluğu şartın belgede yokluğu olarak sunulmaz. “Model uydurmaz” garantisi kaldırıldı. + +Sınır: Atıf numarasının geçerliliği semantik doğruluk kanıtı değildir. Terim tabanlı ilk sekiz parçanın seçimi başka bölümdeki ek/çelişen koşulu kaçırabilir. Tam doküman/ortak kaynak modeli ve gerçek sağlayıcı kabulü halen açık. + +Doğrulama: uzun tek satır ve kısa son madde, Unicode sınırları, geçersiz boyut, sahte/eksik kaynak numarası ve yalnız gerçek atıfların tam içeriğini döndürme için dört test. Tüm 95 test ve üretim derlemesi başarılı. Mevcut büyük paket uyarıları sürüyor. Canlıya yayın yapılmadı. diff --git a/server/ai-teklif.mjs b/server/ai-teklif.mjs index 752d938..2204bb2 100644 --- a/server/ai-teklif.mjs +++ b/server/ai-teklif.mjs @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; +import { questionChunks, qualifyQuestionAnswer } from './document-question.mjs'; import { qualifyOfferAudit } from './offer-audit.mjs'; import { documentContent } from './document-content.mjs'; // Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut @@ -18,17 +20,7 @@ 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); } -/** Metni ~1200 karakterlik parçalara böler (paragraf sınırlarında). */ -export function parcala(metin, hedef = 1200) { - const p = []; let tampon = ''; - for (const satir of String(metin ?? '').replace(/\r/g, '').split('\n')) { - if (tampon.length + satir.length > hedef * 1.5 && tampon.trim()) { p.push(tampon.trim()); tampon = ''; } - tampon += satir + '\n'; - if (tampon.length >= hedef && satir.trim() === '') { p.push(tampon.trim()); tampon = ''; } - } - if (tampon.trim().length >= 40) p.push(tampon.trim()); - return p; -} +export const parcala = questionChunks; /** Terim kapsamasına göre en iyi k parça. */ export function getir(parcalar, soru, k = 8) { @@ -68,13 +60,13 @@ export async function belgeyeSor({ ikn, soru, text, tender, chain }) { 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: 'Belgede bu soruyla eşleşen bir bölüm bulunamadı; belgedeki terimlerle (teminat, teslim, ceza, adet) yeniden deneyin.', kaynaklar: [], kaynak }; - const baglam = kaynaklar.map((k) => `[${k.n}]\n${k.icerik.slice(0, 1400)}`).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 "Belgede bu bilgi bulunamadı" de, 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":"..."}`; + 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, systemOverride: sistem }); - const cevap = String(out?.result?.cevap ?? '').trim() || 'Yanıt üretilemedi.'; - const kullanilan = new Set([...cevap.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]))); - return { cevap, kaynaklar: (kullanilan.size ? kaynaklar.filter((k) => kullanilan.has(k.n)) : kaynaklar.slice(0, 3)).map((k) => ({ n: k.n, alinti: k.icerik.slice(0, 1400) })), kaynak }; + 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.'}; } function gereklilikMetni(tender, gereklilikler, metin) { diff --git a/server/document-question.mjs b/server/document-question.mjs new file mode 100644 index 0000000..8bbf52a --- /dev/null +++ b/server/document-question.mjs @@ -0,0 +1,33 @@ +/** Lossless bounded chunks, including a short last clause and long unbroken lines. */ +export function questionChunks(value, target = 1200) { + if (!Number.isInteger(target) || target < 32 || target > 1400) throw new Error('Geçersiz kaynak parçası boyutu.'); + const text = String(value ?? ''); + const chunks = []; + let offset = 0; + while (offset < text.length) { + let end = Math.min(offset + target, text.length); + if (end < text.length) { + const segment = text.slice(offset, end); + const boundary = Math.max(segment.lastIndexOf('\n'), segment.lastIndexOf(' ')); + if (boundary >= target / 2) end = offset + boundary + 1; + // A Unicode surrogate pair is one character, never two source fragments. + if (/[\uD800-\uDBFF]/.test(text[end - 1]) && /[\uDC00-\uDFFF]/.test(text[end])) end--; + } + chunks.push(text.slice(offset, end)); + offset = end; + } + return chunks; +} + +/** Reject fabricated references rather than attaching unrelated fallback sources. */ +export function qualifyQuestionAnswer(result, selected) { + if (typeof result?.cevap !== 'string' || !result.cevap.trim()) + throw new Error('Model geçerli bir yanıt üretmedi. Yeniden deneyin.'); + const cevap = result.cevap.trim(); + const cited = [...new Set([...cevap.matchAll(/\[(\d+)\]/g)].map(match => Number(match[1])))]; + if (!cited.length) throw new Error('Yanıt kaynak parçasına atıf içermiyor. Yeniden deneyin.'); + const available = new Map(selected.map(part => [part.n, part])); + if (cited.some(id => !available.has(id))) + throw new Error('Yanıttaki kaynak numarası incelenen parçalarla uyuşmuyor. Yeniden deneyin.'); + return {cevap, kaynaklar:cited.map(n => ({n, alinti:available.get(n).icerik}))}; +} diff --git a/src/components/TeklifAsistani.jsx b/src/components/TeklifAsistani.jsx index 1bd785a..5b16ff5 100644 --- a/src/components/TeklifAsistani.jsx +++ b/src/components/TeklifAsistani.jsx @@ -89,7 +89,7 @@ const OfferAssistantSession = ({ tender }) => { dosyaOku(e.target.files?.[0])} /> -
Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtlar yalnızca belgeden; rakamlar aynen aktarılır, model uydurmaz.
+Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtların kaynak parçalarını kontrol edin; AI yanıtları hata veya eksik bilgi içerebilir.
{hata &&{hata}
} {arac === 'sor' && ( @@ -108,6 +108,7 @@ const OfferAssistantSession = ({ tender }) => {{k.alinti}
))}
+ {cevap.kapsam && {cevap.kapsam} {cevap.sourceReview && `${cevap.sourceReview.totalChunks} parçadan ${cevap.sourceReview.selectedChunks} tanesi modele sunuldu.`}
} {cevap.kaynak &&Kaynak: {cevap.kaynak}
} )} diff --git a/tests/document-question.test.mjs b/tests/document-question.test.mjs new file mode 100644 index 0000000..c4deba6 --- /dev/null +++ b/tests/document-question.test.mjs @@ -0,0 +1,27 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {questionChunks,qualifyQuestionAnswer} from '../server/document-question.mjs'; +test('long single-line sources and short final clauses are preserved without prefix truncation',()=>{ + const text='Uzun şartname metni '.repeat(1500)+'Numune zorunludur.'; + const parts=questionChunks(text); + assert.equal(parts.join(''),text);assert.ok(parts.every(p=>p.length<=1200)); + assert.ok(parts.at(-1).includes('Numune zorunludur.')); + const shortTail=questionChunks('x'.repeat(1200)+'Son şart.'); + assert.equal(shortTail[1],'Son şart.'); +}); +test('Unicode boundaries remain intact and chunk sizes are validated',()=>{ + const text='x'.repeat(1199)+'🔬'+'y'.repeat(1300); + const parts=questionChunks(text); + assert.equal(parts.join(''),text);assert.ok(parts.every(p=>!/[\uD800-\uDBFF]$/.test(p))); + for(const size of [0,-1,31,1401,Infinity]) assert.throws(()=>questionChunks(text,size)); +}); +test('unknown, absent and malformed citations cannot become a sourced answer',()=>{ + const parts=[{n:7,icerik:'Teslim süresi 30 gün.'}]; + for(const cevap of ['Teslim süresi 60 gün.','Teslim süresi 30 gün [1].','[7] ve [99]',null,{}]) + assert.throws(()=>qualifyQuestionAnswer({cevap},parts)); +}); +test('only actually cited pieces are returned with their exact untruncated content',()=>{ + const parts=[{n:2,icerik:'İlgisiz madde'},{n:7,icerik:'Teslim süresi 30 gün.\nSon koşul.'}]; + const out=qualifyQuestionAnswer({cevap:'Teslim süresi 30 gün [7]. Kaynak [7].'},parts); + assert.deepEqual(out.kaynaklar,[{n:7,alinti:parts[1].icerik}]); +});