Persist general specification analysis and expose saved results

This commit is contained in:
2026-09-06 12:44:37 +03:00
parent ca0ed73242
commit bfef768974
11 changed files with 122 additions and 42 deletions
+5 -5
View File
@@ -1,8 +1,8 @@
import React,{useEffect,useState,useRef} from 'react';
import {listAiJobs,getAiJob,cancelAiJob,retryAiJob} from '../services/aiJobs';
const labels={queued:'Sırada',running:'İşleniyor',completed:'Tamamlandı',failed:'Hata',interrupted:'Kesildi',cancelled:'İptal edildi'};
const operations={'belge-sor':'Belge sorusu','teklif-denetim':'Taslak denetimi','teklif-taslak':'Taslak üretimi'};
export function AiJobHistory({ikn,onOpen,revision,locked=false}) {
const operations={'analiz':'Şartname analizi','belge-sor':'Belge sorusu','teklif-denetim':'Taslak denetimi','teklif-taslak':'Taslak üretimi'};
export function AiJobHistory({ikn,onOpen,revision,locked=false,operationFilter='offer'}) {
const [jobs,setJobs]=useState([]),[error,setError]=useState(null),[busy,setBusy]=useState(false);
const alive=useRef(true);
useEffect(()=>{alive.current=true;return()=>{alive.current=false;};},[]);
@@ -10,8 +10,8 @@ export function AiJobHistory({ikn,onOpen,revision,locked=false}) {
if(busy)return;setBusy(true);setError(null);
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);};
useEffect(()=>{void act(refresh);},[ikn,revision]);
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]);
return <details className="border rounded-xl p-3 text-xs">
<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>
@@ -22,7 +22,7 @@ export function AiJobHistory({ikn,onOpen,revision,locked=false}) {
<ul className="space-y-2 mt-2">{jobs.map(job=><li key={job.id} className="border rounded p-2">
<p>{operations[job.operation]} · {labels[job.state]} · {new Date(job.createdAt).toLocaleString('tr-TR')}</p>
{job.error&&<p>{job.error}</p>}
{job.state==='completed'&&<button disabled={busy || locked} className="underline py-2 mr-3" onClick={()=>act(async()=>{const data=await getAiJob(job.id);if(alive.current)onOpen(data.job);})}>Kaynak ve sonucu aç</button>}
{job.state==='completed'&&<button disabled={busy || locked} className="underline py-2 mr-3" onClick={()=>act(async()=>{const data=await getAiJob(job.id);if(alive.current)await onOpen(data.job);})}>Kaynak ve sonucu aç</button>}
{['failed','interrupted'].includes(job.state)&&<button disabled={busy} className="underline py-2 mr-3" onClick={()=>act(async()=>{await retryAiJob(job.id);await refresh();})}>İşi yeniden başlat</button>}
{['queued','running'].includes(job.state)&&<button disabled={busy} className="underline py-2" onClick={()=>act(async()=>{await cancelAiJob(job.id);await refresh();})}>İşi iptal et</button>}
</li>)}</ul>
+12 -4
View File
@@ -1,3 +1,5 @@
import { AiJobHistory } from './AiJobHistory';
import {getToken} from '../services/auth';
import React, { useState, useEffect } from 'react';
import { useApp } from '../context/AppContext';
import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react';
@@ -10,13 +12,14 @@ const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleStri
export const AiSpecAnalyzerModal = () => {
const {
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail,
analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail,
} = useApp();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [error, setError] = useState(null);
const [progress, setProgress] = useState('');
const [restoredSource,setRestoredSource]=useState(null);
const [customBid, setCustomBid] = useState(null);
const [aiCfg, setAiCfg] = useState(null);
// Derin mod: ince detaylar, belge kontrol listesi ve yol haritası da üretilir.
@@ -32,11 +35,11 @@ export const AiSpecAnalyzerModal = () => {
// Analiz doküman panelinden de başlatılabiliyor — o durumda isAnalyzing false
// kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz.
const mesgul = isAnalyzing || aiAnalyzing;
const results = aiAnalysisResults;
const results = !activeTenderDetail || aiAnalysisResults?.tenderKey===(activeTenderDetail.ikn || activeTenderDetail.id) ? aiAnalysisResults : null;
const bid = results?.bidAnalysis || {};
const runAnalysis = async (text, fileName) => {
setError(null);
setError(null);setRestoredSource(null);
setIsAnalyzing(true);
setSelectedFile(fileName);
try {
@@ -53,7 +56,7 @@ export const AiSpecAnalyzerModal = () => {
};
const handleFile = async (file) => {
setError(null);
setError(null);setRestoredSource(null);
setIsAnalyzing(true);
setSelectedFile(file.name);
try {
@@ -117,6 +120,11 @@ export const AiSpecAnalyzerModal = () => {
</div>
<div className="p-6 space-y-6 overflow-y-auto">
<AiJobHistory key={`${authUser?.email}:${getToken()}:${activeTenderDetail?.ikn || ''}`} ikn={activeTenderDetail?.ikn}
operationFilter="analiz" locked={mesgul} revision={`${isAiSpecAnalyzerOpen}:${aiAnalyzing}`}
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>}
{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ı */}
{aiCfg && !aiCfg.configured && (
+20 -14
View File
@@ -159,6 +159,7 @@ export const AppProvider = ({ children }) => {
// Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer)
useEffect(() => {
aiRunRef.current=null;setAiAnalyzing(false);
setVeriSenkron(false); setPlan(null); setSaveError(null);
setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]);
setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser));
@@ -336,6 +337,7 @@ export const AppProvider = ({ children }) => {
// Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da
// tetiklenebiliyor; modalın beklerken boş görünmemesi için ortak sinyal.
const [aiAnalyzing, setAiAnalyzing] = useState(false);
const aiRunRef=useRef(null);
// Enterprise Module States
// Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı)
@@ -441,35 +443,38 @@ export const AppProvider = ({ children }) => {
const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id);
const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0];
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
// Gerçek AI analizi — kalıcı sunucu işi üzerinden, admin panelde
// seçilen model zinciri (fallback) ile.
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction, reviewedSource } = {}) => {
const session = getToken();
if(aiRunRef.current?.session===session)throw new Error('Bir analiz zaten sürüyor. Kayıtlı işlerden durumunu kontrol edin.');
const run={session};aiRunRef.current=run;
setAiAnalyzing(true);
setAiAnalysisResults(null);
try {
const { analyzeSpecText } = await import('../services/ai');
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource });
const result = {
...data.result,
tenderKey: tender?.ikn || tender?.id || null,
mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'),
fileName,
model: `${data.model.label} (${data.model.provider})`,
modelId: data.model.model,
fallbackChain: aiModelConfig.chain.map((id) => getModelById(id)?.model).filter(Boolean),
attempts: data.attempts,
analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'),
};
if(getToken() !== session) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.');
const {analysisView}=await import('../services/ai');
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ı.');
setAiAnalysisResults(result);
return result;
} finally {
setAiAnalyzing(false);
if(aiRunRef.current===run){aiRunRef.current=null;setAiAnalyzing(false);}
}
};
const openSavedAnalysis = async job => {
const session=getToken();
if(job?.operation!=='analiz'||job.state!=='completed')throw new Error('Tamamlanmış analiz seçin.');
const {analysisView}=await import('../services/ai');
const result=analysisView(job.result,job.input);
if(getToken()!==session)throw new Error('Oturum değişti.');
setAiAnalysisResults(result);
return result;
};
const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser()));
useEffect(() => {
const veri = {
@@ -777,6 +782,7 @@ export const AppProvider = ({ children }) => {
setUrunAramaBaslangic,
setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini,
openSavedAnalysis,
aiAnalysisResults,
aiAnalyzing,
competitorWatchlist,
+14 -16
View File
@@ -1,7 +1,8 @@
/**
* Gerçek AI şartname analizi istemcisi (backend /api/ai üzerinden).
*/
import { getToken } from './auth';
import { getToken } from './auth.js';
import {runDurableAiJob} from './aiJobs.js';
export async function getAiConfig() {
try {
@@ -13,19 +14,16 @@ export async function getAiConfig() {
}
}
export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction, reviewedSource }) {
const res = await fetch('/api/ai/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ text, fileName, tender, chain, mode, extraction, reviewedSource }),
});
const data = await res.json().catch(() => ({}));
if (!data.success) {
const proxyMesaj = {
413: 'Doküman metni sunucu sınırını aşıyor; daha küçük bir dosya veya yalnızca ilgili sayfaları deneyin.',
504: 'Analiz zaman aşımına uğradı; tekrar deneyin (derin analiz 1-2 dk sürebilir).',
}[res.status];
throw new Error(data.error || proxyMesaj || `Analiz başarısız (HTTP ${res.status})`);
}
return data;
export async function analyzeSpecText(input) {
const session=getToken();
const check=()=>{if(getToken()!==session)throw new Error('Oturum değişti; kayıtlı analiz bu hesaba aktarılmadı.');};
return runDurableAiJob('analiz',input,{check});
}
export function analysisView(data,input) {
if(data?.success!==true || !data.result || typeof data.result!=='object' || Array.isArray(data.result) || !data.model?.label || !data.model?.provider || !Number.isFinite(Date.parse(data.analyzedAt)))throw new Error('Kayıtlı analiz sonucu geçersiz.');
return {...data.result,tenderKey:input.tender?.ikn || input.tender?.id || null,
mode:data.mode || (input.mode==='derin'?'derin':'standart'),fileName:input.fileName,
model:`${data.model.label} (${data.model.provider})`,modelId:data.model.model,
attempts:data.attempts,analyzedAt:new Date(data.analyzedAt).toLocaleString('tr-TR')};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import {accountRequest} from './accountRequest.js';
import {awaitWithAbort} from './abortable.js';
export const listAiJobs=ikn=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({ikn}));
export const listAiJobs=ikn=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams(ikn?{ikn}:{}));
export const getAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}));
export const cancelAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'DELETE'});
export const retryAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'POST',body:JSON.stringify({action:'retry'})});