Bind offer assistant requests and private context to their session
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <OfferAssistantSession key={`${authUser?.email || ''}:${tender.ikn || tender.id}:${identity.current.revision}`} tender={tender} />;
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
<Sekme id="uret" icon={PenLine}>Taslak Üret</Sekme>
|
||||
<label className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-slate-600 cursor-pointer border border-slate-200 rounded-lg px-2 py-1 hover:bg-slate-50">
|
||||
<Upload className="w-3.5 h-3.5" /> {metin ? `Doküman metni yüklendi (${metin.length.toLocaleString('tr-TR')} kr.)` : 'Doküman yükle (.pdf/.txt) — isteğe bağlı'}
|
||||
<input type="file" accept=".pdf,.txt,.md" className="hidden" onChange={(e) => dosyaOku(e.target.files?.[0])} />
|
||||
<input type="file" disabled={mesgul} accept=".pdf,.txt,.md" className="hidden" onChange={(e) => dosyaOku(e.target.files?.[0])} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500">Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtlar yalnızca belgeden; rakamlar aynen aktarılır, model uydurmaz.</p>
|
||||
@@ -76,7 +98,7 @@ export const TeklifAsistani = ({ tender }) => {
|
||||
<input value={soru} onChange={(e) => 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" />
|
||||
<button type="submit" disabled={mesgul} className="bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white font-bold text-xs px-4 rounded-lg">{mesgul ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Sor'}</button>
|
||||
</form>
|
||||
{!cevap && <div className="flex flex-wrap gap-1.5">{ORNEKLER.map((o) => <button key={o} type="button" onClick={() => { setSoru(o); sor(o); }} className="rounded-full border border-slate-200 px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50">{o}</button>)}</div>}
|
||||
{!cevap && <div className="flex flex-wrap gap-1.5">{ORNEKLER.map((o) => <button key={o} type="button" disabled={mesgul} onClick={() => { setSoru(o); sor(o); }} className="rounded-full border border-slate-200 px-2 py-0.5 text-[11px] text-slate-600 hover:bg-slate-50">{o}</button>)}</div>}
|
||||
{cevap && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-800 whitespace-pre-wrap leading-relaxed">{cevap.cevap}</p>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user