Verify analysis recovery in browser and refresh new job status

This commit is contained in:
2026-09-06 12:47:11 +03:00
parent bfef768974
commit fa28fb5f85
7 changed files with 49 additions and 19 deletions
File diff suppressed because one or more lines are too long
+7
View File
@@ -747,3 +747,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
- Oturum başına eşzamanlı genel analiz başlatma kilidi eklendi; eski oturum işi yeni oturumun yükleniyor durumunu veya sonucunu değiştiremez. Farklı ihale seçilince önceki ihalenin analiz sonucu gösterilmez. - Oturum başına eşzamanlı genel analiz başlatma kilidi eklendi; eski oturum işi yeni oturumun yükleniyor durumunu veya sonucunu değiştiremez. Farklı ihale seçilince önceki ihalenin analiz sonucu gösterilmez.
- Doğrulama: 167 otomatik test ve üretim derlemesi geçti. Yeni testler kaynak sabitleme, ihale bazlı iş bulma, sunucu incelemesi kullanımı, inceleme değişikliği ve iptal davranışını kapsıyor. Büyük paket uyarısı sürüyor. - Doğrulama: 167 otomatik test ve üretim derlemesi geçti. Yeni testler kaynak sabitleme, ihale bazlı iş bulma, sunucu incelemesi kullanımı, inceleme değişikliği ve iptal davranışını kapsıyor. Büyük paket uyarısı sürüyor.
- Bu yeni genel analiz ekranı için tarayıcıda kapat/aç/yenile kabul testi ve gerçek sağlayıcı testi henüz yapılmadı. Son 50 kayıt sınırı ve kesilen işi baştan başlatma davranışı sürüyor; ara parçadan devam etme yok. D06 ve tüm plan açık. - Bu yeni genel analiz ekranı için tarayıcıda kapat/aç/yenile kabul testi ve gerçek sağlayıcı testi henüz yapılmadı. Son 50 kayıt sınırı ve kesilen işi baştan başlatma davranışı sürüyor; ara parçadan devam etme yok. D06 ve tüm plan açık.
### Genel analiz tarayıcı kabulü ve liste güncellemesi — 6 Eylül 2026
- Yerel 5179 QA ortamında Giresun ve Kayseri sentetik ilanlarıyla genel analiz başlatıldı. Analiz ekranı kapatıldı, sayfa yeniden yüklendi, kontrollü sağlayıcı yanıtı tamamlandı; kayıtlı kaynak metni/dosya adı ve sonuç tekrar açıldı.
- İlk denemede yeni iş listede elle yenilemeden görünmüyordu. İstemci iş durumu değişikliklerini context'e aktarıyor; liste etkisi önceki devam eden okumadan bağımsız yenileniyor ve geç eski okumayı atıyor. İkinci denemede elle yenilemeden çalışan iş göründü.
- Sunucu anahtarı anlatan teknik uyarı normal kullanıcı için hizmet durumu mesajıyla değiştirildi. Simgeli kapatma düğmesi erişilebilir ad aldı.
- Kanıt: `qa-general-analysis-recovery.json`, 8 kontrol doğru. 167 otomatik test ve üretim derlemesi geçti.
- QA yalnız sentetik sağlayıcı sonucu kullanır; gerçek SQLite iş kaydı/okuması ve genel analiz yürütücüsü kullanıldı. İş başlatma QA tarafından tutulur: üretim işçi yaşam döngüsü, gerçek AI doğruluğu veya sunucu yeniden başlatma kabulü olarak değerlendirilmez.
+8 -1
View File
@@ -11,7 +11,14 @@ export function AiJobHistory({ikn,onOpen,revision,locked=false,operationFilter='
try {await fn();}catch(e){if(alive.current)setError(e.message);}finally {if(alive.current)setBusy(false);} try {await fn();}catch(e){if(alive.current)setError(e.message);}finally {if(alive.current)setBusy(false);}
}; };
const refresh=async()=>{const data=await listAiJobs(ikn);if(!Array.isArray(data.jobs))throw new Error('İş listesi alınamadı.');if(alive.current)setJobs(data.jobs.filter(job=>operationFilter==='offer'?job.operation!=='analiz':job.operation===operationFilter));}; const refresh=async()=>{const data=await listAiJobs(ikn);if(!Array.isArray(data.jobs))throw new Error('İş listesi alınamadı.');if(alive.current)setJobs(data.jobs.filter(job=>operationFilter==='offer'?job.operation!=='analiz':job.operation===operationFilter));};
useEffect(()=>{void act(refresh);},[ikn,revision,operationFilter]); useEffect(()=>{
let current=true;setBusy(true);setError(null);
listAiJobs(ikn).then(data=>{
if(!Array.isArray(data.jobs))throw new Error('İş listesi alınamadı.');
if(current)setJobs(data.jobs.filter(job=>operationFilter==='offer'?job.operation!=='analiz':job.operation===operationFilter));
}).catch(e=>{if(current)setError(e.message);}).finally(()=>{if(current)setBusy(false);});
return()=>{current=false;};
},[ikn,revision,operationFilter]);
return <details className="border rounded-xl p-3 text-xs"> return <details className="border rounded-xl p-3 text-xs">
<summary className="font-bold cursor-pointer">Kayıtlı AI işleri</summary> <summary className="font-bold cursor-pointer">Kayıtlı AI işleri</summary>
<p className="mt-2">Ekran kapansa da sunucudaki işler devam eder. Son 50 iş gösterilir. Kesilen işi yeniden başlatmak sağlayıcıya yeni istek gönderir. Eski sonucu açmak asistanın mevcut metinlerini değiştirir.</p> <p className="mt-2">Ekran kapansa da sunucudaki işler devam eder. Son 50 iş gösterilir. Kesilen işi yeniden başlatmak sağlayıcıya yeni istek gönderir. Eski sonucu açmak asistanın mevcut metinlerini değiştirir.</p>
+4 -12
View File
@@ -12,7 +12,7 @@ const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleStri
export const AiSpecAnalyzerModal = () => { export const AiSpecAnalyzerModal = () => {
const { const {
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail, analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, aiJobStatus, activeAiModel, activeTenderDetail,
} = useApp(); } = useApp();
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
@@ -114,29 +114,21 @@ export const AiSpecAnalyzerModal = () => {
<p className="text-xs text-blue-200">Şartname dosyanızı yükleyin; yeterlik, riskler ve teklif aralığı gerçek AI ile analiz edilsin.</p> <p className="text-xs text-blue-200">Şartname dosyanızı yükleyin; yeterlik, riskler ve teklif aralığı gerçek AI ile analiz edilsin.</p>
</div> </div>
</div> </div>
<button onClick={() => setIsAiSpecAnalyzerOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors"> <button aria-label="Analiz ekranını kapat" onClick={() => setIsAiSpecAnalyzerOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg transition-colors">
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="p-6 space-y-6 overflow-y-auto"> <div className="p-6 space-y-6 overflow-y-auto">
<AiJobHistory key={`${authUser?.email}:${getToken()}:${activeTenderDetail?.ikn || ''}`} ikn={activeTenderDetail?.ikn} <AiJobHistory key={`${authUser?.email}:${getToken()}:${activeTenderDetail?.ikn || ''}`} ikn={activeTenderDetail?.ikn}
operationFilter="analiz" locked={mesgul} revision={`${isAiSpecAnalyzerOpen}:${aiAnalyzing}`} operationFilter="analiz" locked={mesgul} revision={`${isAiSpecAnalyzerOpen}:${aiAnalyzing}:${aiJobStatus}`}
onOpen={async job=>{const result=await openSavedAnalysis(job);setRestoredSource({text:job.input.text,tenderKey:result.tenderKey});setSelectedFile(result.fileName || null);setCustomBid(result.bidAnalysis?.recommendedBid ?? null);setError(null);}} /> onOpen={async job=>{const result=await openSavedAnalysis(job);setRestoredSource({text:job.input.text,tenderKey:result.tenderKey});setSelectedFile(result.fileName || null);setCustomBid(result.bidAnalysis?.recommendedBid ?? null);setError(null);}} />
{results && restoredSource?.tenderKey===results.tenderKey && <details className="border rounded p-3 text-sm"><summary>Kaydedilen analiz kaynak metni</summary><pre className="whitespace-pre-wrap max-h-64 overflow-auto">{restoredSource.text || 'Bu eski kayıtta kaynak metni bulunmuyor.'}</pre></details>} {results && restoredSource?.tenderKey===results.tenderKey && <details className="border rounded p-3 text-sm"><summary>Kaydedilen analiz kaynak metni</summary><pre className="whitespace-pre-wrap max-h-64 overflow-auto">{restoredSource.text || 'Bu eski kayıtta kaynak metni bulunmuyor.'}</pre></details>}
{mesgul&&<p role="status" className="text-sm text-blue-800">Analiz sunucuda sürüyor. Bu ekranı kapatabilirsiniz; sonucu Kayıtlı AI işleri bölümünden açabilirsiniz.</p>} {mesgul&&<p role="status" className="text-sm text-blue-800">Analiz sunucuda sürüyor. Bu ekranı kapatabilirsiniz; sonucu Kayıtlı AI işleri bölümünden açabilirsiniz.</p>}
{/* AI yapılandırılmamış uyarısı */} {/* AI yapılandırılmamış uyarısı */}
{aiCfg && !aiCfg.configured && ( {aiCfg && !aiCfg.configured && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3"> <p role="alert" className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded p-3">AI hizmeti şu anda kullanılamıyor. Kayıtlı sonuçlarınızı açabilirsiniz. Yeni analiz için daha sonra tekrar deneyin veya uygulama yöneticisine bildirin.</p>
<Info className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
<p className="text-xs text-amber-900 leading-relaxed">
<strong>AI henüz yapılandırılmamış.</strong> En kolayı: sunucuya tek bir{' '}
<code className="bg-amber-100 px-1 rounded">OPENROUTER_API_KEY</code> ekleyin —
zincirdeki tüm modeller (GLM, DeepSeek, Gemini, Grok, GPT, Claude) bu tek anahtarla çalışır.
Dilerseniz tek tek sağlayıcı anahtarı da verebilirsiniz; doğrudan anahtar OpenRouter'ı ezer.
Model sırası Admin → AI Model Ayarları'ndan belirlenir.</p>
</div>
)} )}
{/* Uploader */} {/* Uploader */}
+4 -2
View File
@@ -159,7 +159,7 @@ export const AppProvider = ({ children }) => {
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
useEffect(() => { useEffect(() => {
aiRunRef.current=null;setAiAnalyzing(false); aiRunRef.current=null;setAiAnalyzing(false);setAiJobStatus('');
setVeriSenkron(false); setPlan(null); setSaveError(null); setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser)); setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
@@ -338,6 +338,7 @@ export const AppProvider = ({ children }) => {
// tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal. // tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal.
const [aiAnalyzing, setAiAnalyzing] = useState(false); const [aiAnalyzing, setAiAnalyzing] = useState(false);
const aiRunRef=useRef(null); const aiRunRef=useRef(null);
const [aiJobStatus,setAiJobStatus]=useState('');
// Enterprise Module States // Enterprise Module States
// Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı) // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı)
@@ -454,7 +455,7 @@ export const AppProvider = ({ children }) => {
setAiAnalysisResults(null); setAiAnalysisResults(null);
try { try {
const { analyzeSpecText } = await import('../services/ai'); const { analyzeSpecText } = await import('../services/ai');
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource }); const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource },{onProgress:job=>{if(getToken()===session && aiRunRef.current===run)setAiJobStatus(`${job.id}:${job.state}`);}});
const {analysisView}=await import('../services/ai'); const {analysisView}=await import('../services/ai');
const result=analysisView(data,{tender,mode,fileName}); const result=analysisView(data,{tender,mode,fileName});
if(getToken() !== session || aiRunRef.current!==run) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.'); if(getToken() !== session || aiRunRef.current!==run) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.');
@@ -785,6 +786,7 @@ export const AppProvider = ({ children }) => {
openSavedAnalysis, openSavedAnalysis,
aiAnalysisResults, aiAnalysisResults,
aiAnalyzing, aiAnalyzing,
aiJobStatus,
competitorWatchlist, competitorWatchlist,
setCompetitorWatchlist, setCompetitorWatchlist,
vaultDocuments, vaultDocuments,
+2 -2
View File
@@ -14,10 +14,10 @@ export async function getAiConfig() {
} }
} }
export async function analyzeSpecText(input) { export async function analyzeSpecText(input,{onProgress}={}) {
const session=getToken(); const session=getToken();
const check=()=>{if(getToken()!==session)throw new Error('Oturum değişti; kayıtlı analiz bu hesaba aktarılmadı.');}; const check=()=>{if(getToken()!==session)throw new Error('Oturum değişti; kayıtlı analiz bu hesaba aktarılmadı.');};
return runDurableAiJob('analiz',input,{check}); return runDurableAiJob('analiz',input,{check,onProgress});
} }
export function analysisView(data,input) { export function analysisView(data,input) {
+10 -2
View File
@@ -17,6 +17,7 @@ let apiKeyListFailure=false;
process.env.IHALEIST_USER_DATA_DIR=dir; process.env.IHALEIST_USER_DATA_DIR=dir;
const qaJobs=await import('../server/ai-job-store.mjs'); const qaJobs=await import('../server/ai-job-store.mjs');
const {qualifyOfferDraft}=await import('../server/offer-draft.mjs'); const {qualifyOfferDraft}=await import('../server/offer-draft.mjs');
const {executeAnalysisJob}=await import('../server/analysis-job.mjs');
const pendingQaJobs=new Map(); const pendingQaJobs=new Map();
const qaHeartbeat=setInterval(()=>{for(const job of pendingQaJobs.values())qaJobs.heartbeatAiJob(job.id,job.runId);},5000);qaHeartbeat.unref(); const qaHeartbeat=setInterval(()=>{for(const job of pendingQaJobs.values())qaJobs.heartbeatAiJob(job.id,job.runId);},5000);qaHeartbeat.unref();
const probe=netServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r)); const probe=netServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));
@@ -28,9 +29,16 @@ process.env.IHALEIST_SHARED_DATA_DIR=join(dir,'shared');
const {observeTenders}=await import('../server/tender-history.mjs'); const {observeTenders}=await import('../server/tender-history.mjs');
observeTenders(tenders); observeTenders(tenders);
for(let revision=1;revision<=22;revision++) observeTenders([{...tenders[0],saat:revision%2?'11:00':'10:00'}]); for(let revision=1;revision<=22;revision++) observeTenders([{...tenders[0],saat:revision%2?'11:00':'10:00'}]);
const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{target:'http://127.0.0.1:'+port}}},plugins:[{name:'qa-fixtures',configureServer(server){server.middlewares.use((req,res,next)=>{ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{target:'http://127.0.0.1:'+port}}},plugins:[{name:'qa-fixtures',configureServer(server){server.middlewares.use(async(req,res,next)=>{
if(req.url==='/qa-ai-controls') {res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel AI işi testi</h1><p>Sağlayıcı taklidi kullanılır; ücretli AI çağrısı yapılmaz.</p><button id="finish">Bekleyen QA işini tamamla</button><p role="status"></p><script>document.querySelector('#finish').onclick=async()=>{const r=await fetch('/qa-ai-complete',{method:'POST'});document.querySelector('[role=status]').textContent=await r.text();};</script></html>`);return;} if(req.url==='/qa-ai-controls') {res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel AI işi testi</h1><p>Sağlayıcı taklidi kullanılır; ücretli AI çağrısı yapılmaz.</p><button id="finish">Bekleyen QA işini tamamla</button><p role="status"></p><script>document.querySelector('#finish').onclick=async()=>{const r=await fetch('/qa-ai-complete',{method:'POST'});document.querySelector('[role=status]').textContent=await r.text();};</script></html>`);return;}
if(req.url==='/qa-ai-complete' && req.method==='POST') {for(const job of pendingQaJobs.values())qaJobs.finishAiJob(job.id,job.runId,{success:true,...qualifyOfferDraft({bolumler:[{baslik:'Teklif Mektubu (Kapak)',icerik:'QA kayıtlı sonuç: Laboratuvar teklif taslağı. Bu sentetik metin resmi teklif değildir.'}]},['Teklif Mektubu (Kapak)'])});pendingQaJobs.clear();res.end('QA işleri tamamlandı');return;} if(req.url==='/qa-ai-complete' && req.method==='POST') {
for(const job of pendingQaJobs.values()) {
const result=job.operation==='analiz' ? await executeAnalysisJob(job,{pin:qaJobs.pinAiJobInput,analyze:async()=>({result:{summary:'QA kayıtlı genel analiz: Laboratuvar kaynak metni korundu.',items:[],requiredDocuments:[],roadmap:[]},mode:job.input.mode,model:{label:'QA',provider:'sentetik',model:'qa'},attempts:[]})})
: {success:true,...qualifyOfferDraft({bolumler:[{baslik:'Teklif Mektubu (Kapak)',icerik:'QA kayıtlı sonuç: Laboratuvar teklif taslağı. Bu sentetik metin resmi teklif değildir.'}]},['Teklif Mektubu (Kapak)'])};
qaJobs.finishAiJob(job.id,job.runId,result);
}
pendingQaJobs.clear();res.end('QA işleri tamamlandı');return;
}
if(req.url==='/api/me/ai-jobs' && req.method==='POST') { if(req.url==='/api/me/ai-jobs' && req.method==='POST') {
if(req.headers.authorization!=='Bearer '+token){res.writeHead(401);res.end('{}');return;} if(req.headers.authorization!=='Bearer '+token){res.writeHead(401);res.end('{}');return;}
let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=>{try{ let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=>{try{