Persist general specification analysis and expose saved results
This commit is contained in:
@@ -739,3 +739,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö
|
|||||||
- SQLite sütun geçişi işlem kilidi altında yapılır. Sahibe özel iş okuması sabitlenen girdiyi geri verir; eski tamamlanmış işlerin geçmişte edinilen kaynakları geriye dönük üretilemez.
|
- SQLite sütun geçişi işlem kilidi altında yapılır. Sahibe özel iş okuması sabitlenen girdiyi geri verir; eski tamamlanmış işlerin geçmişte edinilen kaynakları geriye dönük üretilemez.
|
||||||
- Doğrulama: 163 otomatik test geçti; üretim derlemesi geçti (büyük paket uyarısı sürüyor). Kaynak A → sağlayıcı hatası → tekrar deneme sırasında A'nın korunması, hesap izolasyonu, idempotency, iptal sonrası geç kaynak ve boş kaynak test edildi.
|
- Doğrulama: 163 otomatik test geçti; üretim derlemesi geçti (büyük paket uyarısı sürüyor). Kaynak A → sağlayıcı hatası → tekrar deneme sırasında A'nın korunması, hesap izolasyonu, idempotency, iptal sonrası geç kaynak ve boş kaynak test edildi.
|
||||||
- Gerçek sağlayıcı/uzun belge ile canlı kabul ve genel AI ekranının kalıcı işlere taşınması henüz tamamlanmadı. Bu kayıt D06'nın bütünüyle bittiği anlamına gelmez.
|
- Gerçek sağlayıcı/uzun belge ile canlı kabul ve genel AI ekranının kalıcı işlere taşınması henüz tamamlanmadı. Bu kayıt D06'nın bütünüyle bittiği anlamına gelmez.
|
||||||
|
|
||||||
|
### Genel şartname analizi için kalıcı işler — 6 Eylül 2026
|
||||||
|
- Genel/derin analiz istemcisi `analiz` türündeki kalıcı işe taşındı. İhale anahtarıyla listelenir; sayfa yenilense veya bağlantı kesilse de sunucu sonucu saklar. Eski doğrudan API uyumluluk için duruyor.
|
||||||
|
- Analiz ekranında kayıtlı genel analizleri açma, kaynak metnini görme, yeniden başlatma ve iptal kontrolleri eklendi. Teklif asistanının iş listesi kendi üç işlem türünü gösteriyor.
|
||||||
|
- Sunucu, insan incelemesi referansını sahibinin belgesinden çözer ve metni sabitler. İnceleme analizden önce veya sonra değişirse tamamlanmış sonuç üretilmez. Yeniden başlatma sabitlenmiş girdiyi kullanır; eski incelemeyi güncel gibi kabul etmez.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {dirname,join} from 'node:path';
|
|||||||
import {fileURLToPath} from 'node:url';
|
import {fileURLToPath} from 'node:url';
|
||||||
import {randomUUID,createHash} from 'node:crypto';
|
import {randomUUID,createHash} from 'node:crypto';
|
||||||
const root=process.env.IHALEIST_USER_DATA_DIR || join(dirname(fileURLToPath(import.meta.url)),'data');
|
const root=process.env.IHALEIST_USER_DATA_DIR || join(dirname(fileURLToPath(import.meta.url)),'data');
|
||||||
const operations=new Set(['belge-sor','teklif-denetim','teklif-taslak']);
|
const operations=new Set(['analiz','belge-sor','teklif-denetim','teklif-taslak']);
|
||||||
function dbWork(fn) {
|
function dbWork(fn) {
|
||||||
mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'ai-jobs.sqlite'));chmodSync(join(root,'ai-jobs.sqlite'),0o600);
|
mkdirSync(root,{recursive:true,mode:0o700});const db=new DatabaseSync(join(root,'ai-jobs.sqlite'));chmodSync(join(root,'ai-jobs.sqlite'),0o600);
|
||||||
try {
|
try {
|
||||||
@@ -40,7 +40,7 @@ export function createAiJob(email,requestId,operation,input) {
|
|||||||
if(db.prepare("SELECT COUNT(*) n FROM ai_jobs WHERE email=? AND state IN ('queued','running')").get(email).n>=5)throw new Error('En fazla beş etkin AI işi olabilir. Mevcut işleri tamamlayın veya iptal edin.');
|
if(db.prepare("SELECT COUNT(*) n FROM ai_jobs WHERE email=? AND state IN ('queued','running')").get(email).n>=5)throw new Error('En fazla beş etkin AI işi olabilir. Mevcut işleri tamamlayın veya iptal edin.');
|
||||||
const id=randomUUID(),now=new Date().toISOString();
|
const id=randomUUID(),now=new Date().toISOString();
|
||||||
db.prepare("INSERT INTO ai_jobs(id,email,request_id,operation,ikn,input,input_hash,state,created_at,updated_at) VALUES (?,?,?,?,?,?,?,'queued',?,?)")
|
db.prepare("INSERT INTO ai_jobs(id,email,request_id,operation,ikn,input,input_hash,state,created_at,updated_at) VALUES (?,?,?,?,?,?,?,'queued',?,?)")
|
||||||
.run(id,email,requestId,operation,input.ikn||null,serialized,hash,now,now);
|
.run(id,email,requestId,operation,input.ikn||input.tender?.ikn||null,serialized,hash,now,now);
|
||||||
const result=publicJob(db.prepare('SELECT * FROM ai_jobs WHERE id=?').get(id));db.exec('COMMIT');return result;
|
const result=publicJob(db.prepare('SELECT * FROM ai_jobs WHERE id=?').get(id));db.exec('COMMIT');return result;
|
||||||
} catch(error){db.exec('ROLLBACK');throw error;}
|
} catch(error){db.exec('ROLLBACK');throw error;}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export async function executeAnalysisJob(job,{analyze,reviewSource,pin,signal}) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
let input=job.input;
|
||||||
|
const reviewed=input.reviewedSource ? reviewSource(job.email,input.reviewedSource,input.tender?.ikn) : null;
|
||||||
|
if(!job.sourcePinned) {
|
||||||
|
const text=reviewed?.text ?? input.text;
|
||||||
|
if(typeof text!=='string'||text.trim().length<40)throw new Error('Analiz için yeterli metin yok (en az 40 karakter).');
|
||||||
|
input={...input,text,fileName:reviewed?.fileName || input.fileName,
|
||||||
|
extraction:reviewed ? {fileName:reviewed.fileName,complete:true,ocr:false} : input.extraction,
|
||||||
|
humanReview:reviewed?.humanReview || null};
|
||||||
|
if(!pin(job.id,job.runId,input))throw new Error('İş artık etkin değil; kaynak kaydedilemedi.');
|
||||||
|
}
|
||||||
|
if(reviewed && reviewed.humanReview.textHash!==input.humanReview?.textHash)throw new Error('İnceleme değişti; güncel kaynakla yeni analiz başlatın.');
|
||||||
|
const out=await analyze({text:input.text,fileName:input.fileName,extraction:input.extraction,tender:input.tender,
|
||||||
|
chain:input.chain,mode:input.mode==='derin'?'derin':undefined,signal});
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if(reviewed) {
|
||||||
|
const latest=reviewSource(job.email,input.reviewedSource,input.tender?.ikn);
|
||||||
|
if(latest.humanReview.textHash!==input.humanReview.textHash)throw new Error('İnceleme analiz sırasında değişti.');
|
||||||
|
out.result.sourceReview={...out.result.sourceReview,humanReview:input.humanReview};
|
||||||
|
}
|
||||||
|
return {success:true,...out,analyzedAt:new Date().toISOString()};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { executeAnalysisJob } from './analysis-job.mjs';
|
||||||
import { resolveAiJobInput } from './ai-job-source.mjs';
|
import { resolveAiJobInput } from './ai-job-source.mjs';
|
||||||
import * as aiJobs from './ai-job-store.mjs';
|
import * as aiJobs from './ai-job-store.mjs';
|
||||||
import { startAiJobRunner } from './ai-job-runner.mjs';
|
import { startAiJobRunner } from './ai-job-runner.mjs';
|
||||||
@@ -689,6 +690,7 @@ async function tendersPayload(force = false) {
|
|||||||
|
|
||||||
const aiJobRunner = startAiJobRunner(async (job,{signal}) => {
|
const aiJobRunner = startAiJobRunner(async (job,{signal}) => {
|
||||||
if(!aiConfigured())throw new Error('AI hizmeti kullanılamıyor. Ayarlar düzeltildikten sonra yeniden başlatın.');
|
if(!aiConfigured())throw new Error('AI hizmeti kullanılamıyor. Ayarlar düzeltildikten sonra yeniden başlatın.');
|
||||||
|
if(job.operation==='analiz')return executeAnalysisJob(job,{analyze:analyzeSpec,reviewSource:reviewedAnalysisSource,pin:aiJobs.pinAiJobInput,signal});
|
||||||
const body=await resolveAiJobInput(job,ihaleMetni,aiJobs.pinAiJobInput,signal,input=>gereklilikMetni(input.tender,input.gereklilikler,'',true));
|
const body=await resolveAiJobInput(job,ihaleMetni,aiJobs.pinAiJobInput,signal,input=>gereklilikMetni(input.tender,input.gereklilikler,'',true));
|
||||||
const shared={signal,ikn:body.ikn,tender:body.tender,chain:body.chain,text:body.text,resolvedSource:body.resolvedSource};
|
const shared={signal,ikn:body.ikn,tender:body.tender,chain:body.chain,text:body.text,resolvedSource:body.resolvedSource};
|
||||||
const result=job.operation==='belge-sor' ? await belgeyeSor({...shared,soru:body.soru})
|
const result=job.operation==='belge-sor' ? await belgeyeSor({...shared,soru:body.soru})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React,{useEffect,useState,useRef} from 'react';
|
import React,{useEffect,useState,useRef} from 'react';
|
||||||
import {listAiJobs,getAiJob,cancelAiJob,retryAiJob} from '../services/aiJobs';
|
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 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'};
|
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}) {
|
export function AiJobHistory({ikn,onOpen,revision,locked=false,operationFilter='offer'}) {
|
||||||
const [jobs,setJobs]=useState([]),[error,setError]=useState(null),[busy,setBusy]=useState(false);
|
const [jobs,setJobs]=useState([]),[error,setError]=useState(null),[busy,setBusy]=useState(false);
|
||||||
const alive=useRef(true);
|
const alive=useRef(true);
|
||||||
useEffect(()=>{alive.current=true;return()=>{alive.current=false;};},[]);
|
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);
|
if(busy)return;setBusy(true);setError(null);
|
||||||
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);};
|
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]);
|
useEffect(()=>{void act(refresh);},[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>
|
||||||
@@ -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">
|
<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>
|
<p>{operations[job.operation]} · {labels[job.state]} · {new Date(job.createdAt).toLocaleString('tr-TR')}</p>
|
||||||
{job.error&&<p>{job.error}</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>}
|
{['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>}
|
{['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>
|
</li>)}</ul>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AiJobHistory } from './AiJobHistory';
|
||||||
|
import {getToken} from '../services/auth';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react';
|
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 = () => {
|
export const AiSpecAnalyzerModal = () => {
|
||||||
const {
|
const {
|
||||||
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
|
isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
|
||||||
analyzeSpecPdfWithGemini, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail,
|
analyzeSpecPdfWithGemini, openSavedAnalysis, authUser, aiAnalysisResults, aiAnalyzing, activeAiModel, activeTenderDetail,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [progress, setProgress] = useState('');
|
const [progress, setProgress] = useState('');
|
||||||
|
const [restoredSource,setRestoredSource]=useState(null);
|
||||||
const [customBid, setCustomBid] = useState(null);
|
const [customBid, setCustomBid] = useState(null);
|
||||||
const [aiCfg, setAiCfg] = useState(null);
|
const [aiCfg, setAiCfg] = useState(null);
|
||||||
// Derin mod: ince detaylar, belge kontrol listesi ve yol haritası da üretilir.
|
// 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
|
// Analiz doküman panelinden de başlatılabiliyor — o durumda isAnalyzing false
|
||||||
// kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz.
|
// kalır, bu yüzden context'teki ortak sinyalle birleştiriyoruz.
|
||||||
const mesgul = isAnalyzing || aiAnalyzing;
|
const mesgul = isAnalyzing || aiAnalyzing;
|
||||||
const results = aiAnalysisResults;
|
const results = !activeTenderDetail || aiAnalysisResults?.tenderKey===(activeTenderDetail.ikn || activeTenderDetail.id) ? aiAnalysisResults : null;
|
||||||
const bid = results?.bidAnalysis || {};
|
const bid = results?.bidAnalysis || {};
|
||||||
|
|
||||||
const runAnalysis = async (text, fileName) => {
|
const runAnalysis = async (text, fileName) => {
|
||||||
setError(null);
|
setError(null);setRestoredSource(null);
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
setSelectedFile(fileName);
|
setSelectedFile(fileName);
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +56,7 @@ export const AiSpecAnalyzerModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFile = async (file) => {
|
const handleFile = async (file) => {
|
||||||
setError(null);
|
setError(null);setRestoredSource(null);
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
setSelectedFile(file.name);
|
setSelectedFile(file.name);
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +120,11 @@ export const AiSpecAnalyzerModal = () => {
|
|||||||
</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}
|
||||||
|
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ı */}
|
{/* AI yapılandırılmamış uyarısı */}
|
||||||
{aiCfg && !aiCfg.configured && (
|
{aiCfg && !aiCfg.configured && (
|
||||||
|
|||||||
+20
-14
@@ -159,6 +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);
|
||||||
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));
|
||||||
@@ -336,6 +337,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
// Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da
|
// 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.
|
// 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);
|
||||||
|
|
||||||
// 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ı)
|
||||||
@@ -441,35 +443,38 @@ export const AppProvider = ({ children }) => {
|
|||||||
const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id);
|
const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id);
|
||||||
const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0];
|
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.
|
// seçilen model zinciri (fallback) ile.
|
||||||
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
|
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
|
||||||
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction, reviewedSource } = {}) => {
|
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction, reviewedSource } = {}) => {
|
||||||
const session = getToken();
|
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);
|
setAiAnalyzing(true);
|
||||||
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 });
|
||||||
const result = {
|
const {analysisView}=await import('../services/ai');
|
||||||
...data.result,
|
const result=analysisView(data,{tender,mode,fileName});
|
||||||
tenderKey: tender?.ikn || tender?.id || null,
|
if(getToken() !== session || aiRunRef.current!==run) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.');
|
||||||
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ı.');
|
|
||||||
setAiAnalysisResults(result);
|
setAiAnalysisResults(result);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} 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()));
|
const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser()));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const veri = {
|
const veri = {
|
||||||
@@ -777,6 +782,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
setUrunAramaBaslangic,
|
setUrunAramaBaslangic,
|
||||||
setIsAiSpecAnalyzerOpen,
|
setIsAiSpecAnalyzerOpen,
|
||||||
analyzeSpecPdfWithGemini,
|
analyzeSpecPdfWithGemini,
|
||||||
|
openSavedAnalysis,
|
||||||
aiAnalysisResults,
|
aiAnalysisResults,
|
||||||
aiAnalyzing,
|
aiAnalyzing,
|
||||||
competitorWatchlist,
|
competitorWatchlist,
|
||||||
|
|||||||
+13
-15
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Gerçek AI şartname analizi istemcisi (backend /api/ai üzerinden).
|
* 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() {
|
export async function getAiConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -13,19 +14,16 @@ export async function getAiConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction, reviewedSource }) {
|
export async function analyzeSpecText(input) {
|
||||||
const res = await fetch('/api/ai/analyze', {
|
const session=getToken();
|
||||||
method: 'POST',
|
const check=()=>{if(getToken()!==session)throw new Error('Oturum değişti; kayıtlı analiz bu hesaba aktarılmadı.');};
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
return runDurableAiJob('analiz',input,{check});
|
||||||
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 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,6 +1,6 @@
|
|||||||
import {accountRequest} from './accountRequest.js';
|
import {accountRequest} from './accountRequest.js';
|
||||||
import {awaitWithAbort} from './abortable.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 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 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'})});
|
export const retryAiJob=id=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({id}),{method:'POST',body:JSON.stringify({action:'retry'})});
|
||||||
|
|||||||
@@ -83,3 +83,11 @@ test('cancel during source fetch rejects late source, and empty resolution remai
|
|||||||
assert.deepEqual(await resolveAiJobInput(retry,()=>assert.fail('must not refetch'),jobs.pinAiJobInput),input);
|
assert.deepEqual(await resolveAiJobInput(retry,()=>assert.fail('must not refetch'),jobs.pinAiJobInput),input);
|
||||||
jobs.cancelAiJob('source-owner',empty.id);
|
jobs.cancelAiJob('source-owner',empty.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('general analysis jobs are indexed by tender for recovery',()=>{
|
||||||
|
const job=jobs.createAiJob('analysis-owner','request-analysis','analiz',{tender:{ikn:'2026/777'},text:'Analiz kaynağı '.repeat(5)});
|
||||||
|
assert.equal(job.ikn,'2026/777');
|
||||||
|
assert.equal(jobs.listAiJobs('analysis-owner','2026/777')[0].id,job.id);
|
||||||
|
assert.equal(jobs.listAiJobs('analysis-owner','2026/778').length,0);
|
||||||
|
jobs.cancelAiJob('analysis-owner',job.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {executeAnalysisJob} from '../server/analysis-job.mjs';
|
||||||
|
const input={text:'Kaynak metni '.repeat(10),tender:{ikn:'2026/1'},mode:'derin'};
|
||||||
|
const output=()=>({result:{summary:'Analiz'},model:{label:'QA',provider:'test'}});
|
||||||
|
test('general analysis pins source and passes cancellation signal to provider',async()=>{
|
||||||
|
let pinned,request;const signal=new AbortController().signal;
|
||||||
|
const result=await executeAnalysisJob({id:'a',runId:'r',input},{signal,pin:(id,runId,data)=>{pinned=data;return true;},analyze:async data=>{request=data;return output();}});
|
||||||
|
assert.equal(pinned.text,input.text);assert.equal(request.signal,signal);assert.equal(request.mode,'derin');assert.equal(result.success,true);
|
||||||
|
assert.ok(Number.isFinite(Date.parse(result.analyzedAt)));
|
||||||
|
await assert.rejects(executeAnalysisJob({input:{text:'short'}},{pin:()=>assert.fail(),analyze:()=>assert.fail()}),/en az 40/);
|
||||||
|
});
|
||||||
|
test('reviewed analysis uses owner source and refuses changed review before or after provider',async()=>{
|
||||||
|
const reviewed={text:'Sunucu inceleme metni '.repeat(5),fileName:'checked.pdf',humanReview:{textHash:'A'}};
|
||||||
|
const job={id:'a',runId:'r',email:'owner',input:{...input,reviewedSource:{documentId:'doc',revision:1},humanReview:{textHash:'forged'}}};
|
||||||
|
let pinned,calls=0;
|
||||||
|
await assert.rejects(executeAnalysisJob(job,{pin:(id,runId,data)=>{pinned=data;return true;},reviewSource:(email,ref,key)=>{
|
||||||
|
assert.equal(email,'owner');assert.equal(key,'2026/1');calls++;return calls===1?reviewed:{...reviewed,humanReview:{textHash:'B'}};
|
||||||
|
},analyze:async request=>{assert.equal(request.text,reviewed.text);return output();}}),/analiz sırasında değişti/);
|
||||||
|
assert.equal(pinned.humanReview.textHash,'A');
|
||||||
|
await assert.rejects(executeAnalysisJob({...job,sourcePinned:true,input:pinned},{reviewSource:()=>({...reviewed,humanReview:{textHash:'B'}}),analyze:()=>assert.fail('stale source must not run')}),/İnceleme değişti/);
|
||||||
|
});
|
||||||
|
test('cancelled source pin prevents provider call and cancellation after provider rejects result',async()=>{
|
||||||
|
await assert.rejects(executeAnalysisJob({input},{pin:()=>false,analyze:()=>assert.fail()}),/artık etkin değil/);
|
||||||
|
const controller=new AbortController();
|
||||||
|
await assert.rejects(executeAnalysisJob({input},{signal:controller.signal,pin:()=>true,analyze:async()=>{controller.abort();return output();}}),{name:'AbortError'});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user