diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index a482ad7..7a8db25 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -421,3 +421,13 @@ Yerel 5175 test hesabında gerçek `hemogram` araması 2.531 sonuç bildirdi. An Bu akışta kaynakta 3.3 teslim yeri ve 3.4 teslim süresi bulunurken özet alanların boş kaldığı gözlendi. `/api/documents/ilan` yanıtına mevcut kaynak alanı ayrıştırıcısı bağlandı. EKAP HTML tablolarının metne dönüşümündeki `| : |` ayraçları yalnız ayrıştırma kopyasında normalleştirildi; özgün ilan metni korunuyor. Gerçek yanıtta görülen tablo biçimiyle tarih/saat, teslim yeri ve tam teslim süresi için regresyon testi eklendi. Doğrulama: `npm test` ve `npm run build` başarılı. Mevcut büyük paket/statik-dinamik import uyarıları sürüyor. Çalışan eski test sunucusu yeni backend değişikliğini yüklemediğinden yeni özet alanlar için tarayıcı kabulü henüz tamamlanmadı. Son kanıt dosyası oluşturma denemesinde seçici zaman aşımı oluştu; dosya oluşturulduğu iddia edilmiyor. Yukarıdaki takip kalıcılığı bulgusu daha önce başarıyla dönen tarayıcı okumalarına dayanır. D01–D11 planının tümü tamamlanmış değildir. + +### D06 / D09 — Teklif Asistanı işlem ve hesap ayrımı + +Kod incelemesinde Taslak Üret akışının belge listesini bekledikten sonra güncel token ile POST attığı doğrulandı. Hesap değişirse eski hesaptan gelen belge adları yeni hesap oturumunda gönderilebiliyordu. Belge listeleme hatası da boş diziye çevrilerek sessizce devam ediyordu. + +`requestOfferAssistant` tüm adımları başlangıç tokenına ve etkin görünüme bağlar. Belge listesi beklenirken hesap/ihale değişirse AI isteği gönderilmez; geç yanıtın oturumu değişmişse yanıt reddedilir. Gönderimde başlangıç tokenı kullanılır. Belge listesi yüklenememesi veya geçersiz liste kullanıcıya hata olarak döner. Envanter yalnız firma belgeleri ve mevcut İKN belgeleriyle sınırlandırılır. + +Teklif Asistanı durumu hesap, token oturumu veya İKN değiştiğinde yeniden oluşturulur. Bileşen kapanınca dosya okuma/AI sonucu işlenmez. Eşzamanlı düğme ve dosya işlemleri kilitlenir. Yeni kaynak dosyası yüklenince önceki yanıt/denetim temizlenir; kullanıcının düzenlediği taslak bölümleri korunur. + +Doğrulama: dört yeni test; belge listesi sırasında hesap değişiminde sıfır AI isteği, envanter hatasında sıfır AI isteği, doğru belge kapsamı ve başlangıç yetkilendirmesi, geç yanıtta hesap/görünüm değişimi. Tüm 87 test geçti. Üretim derlemesi başarılı; büyük paket uyarıları mevcut. Gerçek AI sağlayıcısı ve iki hesaplı tarayıcı kabulü bu turda yapılmadı. D01–D11 kapsamının tamamı halen açık. diff --git a/src/components/TeklifAsistani.jsx b/src/components/TeklifAsistani.jsx index 4095825..8f64135 100644 --- a/src/components/TeklifAsistani.jsx +++ b/src/components/TeklifAsistani.jsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react'; import { getToken } from '../services/auth'; import { listDocuments } from '../services/account'; +import { useApp } from '../context/AppContext'; +import { requestOfferAssistant } from '../services/offerAssistant'; /** * Teklif Asistanı sekmesi — üç araç (İhaleAB'den taşındı): @@ -10,17 +12,24 @@ import { listDocuments } from '../services/account'; * 3) Taslak Üret: kapak / teknik / ticari / tam dosya (rakam uydurmaz, [DOLDURUN]) * Doküman metni sunucuda EKAP'tan alınır; yoksa buradan .txt/.pdf metni yapıştırılır. */ -const post = async (url, body) => { - const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, body: JSON.stringify(body) }); - const d = await r.json().catch(() => ({})); - if (!r.ok || d.success === false) throw new Error(d.error || `HTTP ${r.status}`); - return d; -}; - const ORNEKLER = ['Geçici teminat oranı ve süresi nedir?', 'Teslim süresi kaç gün?', 'Gecikme cezası ne kadar?', 'Hangi belgeler istenmiş?', 'Numune isteniyor mu?']; const DURUM = { karsilaniyor: ['Karşılanıyor', 'bg-emerald-100 text-emerald-800 border-emerald-200'], eksik: ['Eksik', 'bg-red-100 text-red-800 border-red-200'], belirsiz: ['Belirsiz', 'bg-amber-100 text-amber-800 border-amber-200'] }; export const TeklifAsistani = ({ tender }) => { + const {authUser} = useApp(); + const token = getToken(); + const identity = useRef({token, revision:0}); + if (identity.current.token !== token) identity.current = {token, revision:identity.current.revision + 1}; + return ; +}; + +const OfferAssistantSession = ({ tender }) => { + const live = useRef(true); + const session = useRef(getToken()); + const busy = useRef(false); + useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); + const current = () => live.current && Boolean(session.current) && getToken() === session.current; + const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents}); const [arac, setArac] = useState('sor'); const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni const [soru, setSoru] = useState(''); @@ -32,21 +41,34 @@ export const TeklifAsistani = ({ tender }) => { const [mesgul, setMesgul] = useState(false); const [hata, setHata] = useState(null); - const calistir = async (fn) => { setMesgul(true); setHata(null); try { await fn(); } catch (e) { setHata(e.message); } finally { setMesgul(false); } }; - const sor = (q) => calistir(async () => { setCevap(await post('/api/ai/belge-sor', { ikn: tender.ikn, soru: q, text: metin || undefined, tender })); }); - const denetle = () => calistir(async () => { setDenetim(await post('/api/ai/teklif-denetim', { ikn: tender.ikn, taslak, tender, text: metin || undefined })); }); - const uret = () => calistir(async () => { - const belgeler = await listDocuments().then((d) => (d.belgeler || []).map((b) => ({ ad: b.ad, kategori: b.kategori }))).catch(() => []); - setBolumler((await post('/api/ai/teklif-taslak', { ikn: tender.ikn, tur, tender, belgeler, text: metin || undefined })).bolumler || []); + const calistir = async (fn) => { + if (busy.current || !current()) return; + busy.current = true; setMesgul(true); setHata(null); + try { await fn(); } + catch (e) { if (current()) setHata(e.message); } + finally { busy.current = false; if (current()) setMesgul(false); } + }; + const sor = (q) => calistir(async () => { + const result = await post('/api/ai/belge-sor', {ikn:tender.ikn, soru:q, text:metin || undefined, tender}); + if (current()) setCevap(result); }); - const kopyala = (t) => navigator.clipboard?.writeText(t).catch(() => {}); - const dosyaOku = async (f) => { + const denetle = () => calistir(async () => { + const result = await post('/api/ai/teklif-denetim', {ikn:tender.ikn, taslak, tender, text:metin || undefined}); + if (current()) setDenetim(result); + }); + const uret = () => calistir(async () => { + const result = await post('/api/ai/teklif-taslak', {ikn:tender.ikn, tur, tender, text:metin || undefined}, listDocuments); + if (current()) setBolumler(result.bolumler || []); + }); + const kopyala = (t) => { if (current()) navigator.clipboard?.writeText(t).catch(() => {}); }; + const dosyaOku = (f) => { if (!f) return; - try { + return calistir(async () => { const { extractText } = await import('../services/pdfText'); + if (!current()) return; const r = await extractText(f); - setMetin(r.text || ''); - } catch (e) { setHata('Dosya okunamadı: ' + e.message); } + if (current()) { setMetin(r.text || ''); setCevap(null); setDenetim(null); } + }); }; const Sekme = ({ id, icon: Icon, children }) => ( @@ -64,7 +86,7 @@ export const TeklifAsistani = ({ tender }) => { Taslak Üret

Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtlar yalnızca belgeden; rakamlar aynen aktarılır, model uydurmaz.

@@ -76,7 +98,7 @@ export const TeklifAsistani = ({ tender }) => { setSoru(e.target.value)} placeholder="Ör. Geçici teminat oranı nedir?" className="flex-1 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm" /> - {!cevap &&
{ORNEKLER.map((o) => )}
} + {!cevap &&
{ORNEKLER.map((o) => )}
} {cevap && (

{cevap.cevap}

diff --git a/src/services/offerAssistant.js b/src/services/offerAssistant.js new file mode 100644 index 0000000..20833c1 --- /dev/null +++ b/src/services/offerAssistant.js @@ -0,0 +1,25 @@ +/** Bind the entire multi-step request to its starting account and mounted view. */ +export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch}) { + const token = readToken(); + const check = () => { + if (!token || readToken() !== token || !isCurrent()) + throw new Error('Oturum veya ihale değişti. İşlemi güncel ekrandan yeniden başlatın.'); + }; + check(); + let payload = body; + if (listDocuments) { + const result = await listDocuments(); + check(); + // An unavailable inventory must not be represented as an empty inventory. + if (!Array.isArray(result.belgeler)) throw new Error('Belge listesi doğrulanamadı. Yeniden deneyin.'); + payload = {...body, belgeler: result.belgeler + .filter(b => b.tur === 'firma' || b.ikn === body.ikn) + .map(b => ({ad:b.ad, kategori:b.kategori}))}; + } + check(); + const response = await fetcher(url, {method:'POST', headers:{'Content-Type':'application/json', Authorization:`Bearer ${token}`}, body:JSON.stringify(payload)}); + const data = await response.json().catch(() => ({})); + check(); + if (!response.ok || data.success === false) throw new Error(data.error || `HTTP ${response.status}`); + return data; +} diff --git a/tests/offer-assistant.test.mjs b/tests/offer-assistant.test.mjs new file mode 100644 index 0000000..88d329c --- /dev/null +++ b/tests/offer-assistant.test.mjs @@ -0,0 +1,36 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {requestOfferAssistant} from '../src/services/offerAssistant.js'; +const body={ikn:'2026/123',text:'Private source'}; +test('account switch while inventory loads cannot send private context under the next account',async()=>{ + let token='first', calls=0; + await assert.rejects(requestOfferAssistant({url:'/draft',body,readToken:()=>token, + listDocuments:async()=>{token='second';return {belgeler:[{ad:'Private document',tur:'firma'}]};}, + fetcher:async()=>{calls++;return Response.json({success:true});}}),/Oturum veya ihale değişti/); + assert.equal(calls,0); +}); +test('inventory errors stop generation and do not silently become empty documents',async()=>{ + let calls=0; + for(const listDocuments of [async()=>{throw Error('Inventory unavailable');},async()=>({})]) { + await assert.rejects(requestOfferAssistant({url:'/draft',body,readToken:()=>'first',listDocuments, + fetcher:async()=>{calls++;return Response.json({});}})); + } + assert.equal(calls,0); +}); +test('generation uses only company or matching tender documents and retains starting authorization',async()=>{ + const result=await requestOfferAssistant({url:'/draft',body,readToken:()=>'first', + listDocuments:async()=>({belgeler:[{ad:'Company',tur:'firma'},{ad:'Source',tur:'ihale',ikn:'2026/123'},{ad:'Other tender',tur:'ihale',ikn:'2026/999'}]}), + fetcher:async(url,options)=>{ + assert.equal(options.headers.Authorization,'Bearer first'); + assert.deepEqual(JSON.parse(options.body).belgeler,[{ad:'Company'},{ad:'Source'}]); + return Response.json({success:true,bolumler:[]}); + }}); + assert.equal(result.success,true); +}); +test('late answer is rejected after account or active tender changes',async()=>{ + for (const change of ['account','view']) { + let token='first', mounted=true; + await assert.rejects(requestOfferAssistant({url:'/ask',body,readToken:()=>token,isCurrent:()=>mounted, + fetcher:async()=>{if(change==='account')token='second';else mounted=false;return Response.json({cevap:'Private answer'});}}),/Oturum veya ihale değişti/); + } +});