Persist offer assistant jobs and recover results across closed views
This commit is contained in:
@@ -713,3 +713,13 @@ Taslak denetimi için altı anahtar sözcüğün ilk 300 karakterlik örneklerin
|
||||
UI kaynak karakter/parça/alıntı sayısını gösterir. Karşılanma yüzdesi hâlâ yalnız denetlenen liste içindir. Bütün parçaların taranması AI'ın tüm şartları eksiksiz seçtiğini veya alıntıyı anlam bakımından doğru değerlendirdiğini kanıtlamaz; parça sınırları, bağlam ve çelişen hükümler ayrıca incelenmelidir. Bu sınır kullanıcı açıklamasında korunur.
|
||||
|
||||
Test: 15 bin karakterlik metnin tüm karakterleri modele giden parçalarda, son şart ve tam konumu korunuyor; uydurma alıntı/geçersiz liste/ara sağlayıcı hatası reddediliyor; iptal sonraki grupları durduruyor. Sağlayıcı yanıtları yerel test taklididir. Tüm 155 test ve üretim derlemesi geçti; UI sayacı sonrasında derleme tekrar doğrulandı. Gerçek sağlayıcıyla uzun belge kalitesi, kalıcı iş/iki dakikadan uzun tamamlama ve beş resmi dosya kabulü açık. Canlı yayın yok.
|
||||
|
||||
### D06 — Kalıcı teklif asistanı işleri
|
||||
|
||||
Teklif asistanının soru, taslak denetimi ve taslak üretimi kalıcı işe bağlandı. İş girdisi ve sonucu kullanıcı veri dizinindeki ai-jobs.sqlite dosyasında tutulur; API erişimi hesapla sınırlıdır. Tekrar kullanılan istek kimliği aynı girdide aynı işi döndürür, farklı girdide reddedilir. Kullanıcı başına beş etkin iş ve işlemci başına iki eşzamanlı çalışma sınırı vardır.
|
||||
|
||||
İşlemci queued/running/completed/failed/interrupted/cancelled durumlarını kullanır. Çalışma kimliği ve heartbeat, eski işlemcinin geç yanıtla iptal/yeniden başlatılmış işi değiştirmesini engeller. Heartbeat kaybolursa iş interrupted olur; belirsiz sağlayıcı çağrısı otomatik tekrarlanmaz. Yeniden başlatma kullanıcı tarafından yapılır ve işi baştan çalıştırır; parça bazında checkpoint devamı değildir. İş başına 30 dakika sınırı vardır.
|
||||
|
||||
İstemci tek uzun HTTP isteği yerine işi oluşturur ve kısa durum istekleriyle sonucu bekler. Ekran kapanınca yalnız takip isteği kesilir; sunucu işi devam eder. Kayıtlı AI işleri bölümünde son 50 iş yenilenebilir, iptal/yeniden başlatılabilir; tamamlanmış işin kaynağı ve sonucu asistan ekranına geri açılabilir. Eski işi açmanın mevcut metni değiştirdiği belirtilir. Doğrudan eski API uçları uyumluluk için korunur.
|
||||
|
||||
Doğrulama: hesap ayrımı, idempotency, kesilen işin otomatik tekrarlanmaması, çalışma kimliği, iptal sonrası geç sonuç reddi, HTTP bağlantısından bağımsız sonuç kaydı; istemci oluşturma/poll/tamamlama ve ekran kapanınca silme çağrısı olmaması; HTTP yetki ve boş liste. Tüm 161 test ve üretim derlemesi geçti. Sağlayıcı testleri taklittir; gerçek uzun işlem/tarayıcı geri açma kabulü yapılmadı. Genel AI analiz ekranı henüz bu iş modeline bağlı değil. ai-jobs.sqlite kullanıcı yedek aracına otomatik dahil değildir; veri volume yedeğiyle ayrıca alınmalı. Canlı yayın yok.
|
||||
|
||||
@@ -49,3 +49,5 @@ node server/backup-data-volume.mjs restore /backups/shared-release-001 /recovery
|
||||
Bu araç seçilen dizin dışındaki worker depolarını, ortam değişkenlerini veya dağıtım yapılandırmasını kendiliğinden bulmaz. Kullanıcı belgelerinde veritabanı-dosya referans kontrolü için kullanıcı yedek aracını da kullanın. Tüm veri dizinleri ve kullanıcı yedeği aynı durdurulmuş sürümün seti olarak alınmalı; manifest zamanları tek başına servislerin durduğunu kanıtlamaz. Şifreleme/imzalama yapılmaz; backup erişimi ve dış saklama platformda yönetilir. Fiziksel boş dizinler ayrı manifest girdileri değildir.
|
||||
|
||||
`tests/data-volume-backup.test.mjs`, iki SQLite deposu, committed WAL verisi, alt dizin dosyası, restore, hedef üzerine yazmama, değişmiş/ek dosya ve sembolik bağlantı reddini doğrular. Gerçek üretim volume'larına bu komutlar henüz uygulanmadı; platform geri dönüş provası açık.
|
||||
|
||||
Kalıcı teklif asistanı işleri `IHALEIST_USER_DATA_DIR/ai-jobs.sqlite` içinde özel kaynak ve sonuç metni içerir. `backup-user-data.mjs` yalnız users.sqlite ve referanslı belgeleri aldığı için bu dosya ayrıca volume aracıyla yedeklenmelidir. Bütün yazıcılar durdurulmadan job/user yedek seti tutarlı kabul edilmez.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {claimAiJob,heartbeatAiJob,finishAiJob} from './ai-job-store.mjs';
|
||||
|
||||
export function startAiJobRunner(execute,{intervalMs=2000,concurrency=2}={}) {
|
||||
const active=new Map();let stopped=false;
|
||||
async function run(job) {
|
||||
const controller=new AbortController();active.set(job.id,controller);
|
||||
const heartbeat=setInterval(()=>{
|
||||
try {if(!heartbeatAiJob(job.id,job.runId))controller.abort();}catch {controller.abort();}
|
||||
},10000);
|
||||
const timeout=setTimeout(()=>controller.abort(new Error('AI işi süre sınırına ulaştı. Yeniden deneyin.')),30*60*1000);
|
||||
try {
|
||||
const result=await execute(job,{signal:controller.signal});
|
||||
controller.signal.throwIfAborted();
|
||||
finishAiJob(job.id,job.runId,result);
|
||||
}catch(error){
|
||||
try {finishAiJob(job.id,job.runId,null,String(error.message || 'AI işi tamamlanamadı.'));}catch { /* lease expiry exposes interrupted work */ }
|
||||
}finally {clearInterval(heartbeat);clearTimeout(timeout);active.delete(job.id);}
|
||||
}
|
||||
const tick=()=>{
|
||||
if(stopped)return;
|
||||
try {while(active.size<concurrency){const job=claimAiJob();if(!job)break;void run(job);}}
|
||||
catch { /* keep durable work queued until the store is available */ }
|
||||
};
|
||||
const timer=setInterval(tick,intervalMs);timer.unref();
|
||||
return {wake:tick,cancel:id=>active.get(id)?.abort(),stop:()=>{stopped=true;clearInterval(timer);for(const controller of active.values())controller.abort();}};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {mkdirSync,chmodSync} from 'node:fs';
|
||||
import {dirname,join} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
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']);
|
||||
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);
|
||||
try {
|
||||
db.exec(`PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS ai_jobs(id TEXT PRIMARY KEY,email TEXT NOT NULL,request_id TEXT NOT NULL,operation TEXT NOT NULL,ikn TEXT,
|
||||
input TEXT NOT NULL,input_hash TEXT NOT NULL,state TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,
|
||||
result TEXT,error TEXT,run_id TEXT,heartbeat INTEGER,UNIQUE(email,request_id));
|
||||
CREATE INDEX IF NOT EXISTS job_owner ON ai_jobs(email,created_at);
|
||||
CREATE INDEX IF NOT EXISTS job_queue ON ai_jobs(state,created_at);`);
|
||||
return fn(db);
|
||||
}finally {db.close();}
|
||||
}
|
||||
function publicJob(row) {
|
||||
if(!row)return null;
|
||||
return {id:row.id,operation:row.operation,ikn:row.ikn,state:row.state,createdAt:row.created_at,updatedAt:row.updated_at,
|
||||
result:row.result?JSON.parse(row.result):null,error:row.error,inputHash:row.input_hash};
|
||||
}
|
||||
export function createAiJob(email,requestId,operation,input) {
|
||||
if(typeof requestId!=='string'||requestId.length<8||requestId.length>100||!operations.has(operation)||!input||typeof input!=='object'||Array.isArray(input))throw new Error('Geçersiz AI işi.');
|
||||
const serialized=JSON.stringify(input),hash=createHash('sha256').update(operation+'\n'+serialized).digest('hex');
|
||||
if(Buffer.byteLength(serialized)>2*1024*1024)throw new Error('AI kaynak verisi çok büyük.');
|
||||
return dbWork(db=>{
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const previous=db.prepare('SELECT * FROM ai_jobs WHERE email=? AND request_id=?').get(email,requestId);
|
||||
if(previous) {if(previous.input_hash!==hash)throw new Error('İstek kimliği farklı kaynak için kullanılmış.');db.exec('COMMIT');return publicJob(previous);}
|
||||
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();
|
||||
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);
|
||||
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;}
|
||||
});
|
||||
}
|
||||
export const getAiJob=(email,id)=>dbWork(db=>{const row=db.prepare('SELECT * FROM ai_jobs WHERE email=? AND id=?').get(email,id);return row?{...publicJob(row),input:JSON.parse(row.input)}:null;});
|
||||
export const listAiJobs=(email,ikn)=>dbWork(db=>db.prepare('SELECT * FROM ai_jobs WHERE email=? AND (? IS NULL OR ikn=?) ORDER BY created_at DESC LIMIT 50').all(email,ikn||null,ikn||null).map(publicJob));
|
||||
export function claimAiJob(clock=Date.now()) {
|
||||
return dbWork(db=>{
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
// Do not replay an uncertain provider call automatically after a lost worker.
|
||||
db.prepare("UPDATE ai_jobs SET state='interrupted',error=?,updated_at=? WHERE state='running' AND heartbeat<?")
|
||||
.run('İşlem kesildi. Kaynak korunuyor; yeniden başlatabilirsiniz.',new Date(clock).toISOString(),clock-60000);
|
||||
const row=db.prepare("SELECT * FROM ai_jobs WHERE state='queued' ORDER BY created_at LIMIT 1").get();
|
||||
if(!row){db.exec('COMMIT');return null;}
|
||||
const runId=randomUUID();
|
||||
db.prepare("UPDATE ai_jobs SET state='running',run_id=?,heartbeat=?,updated_at=? WHERE id=?").run(runId,clock,new Date(clock).toISOString(),row.id);
|
||||
db.exec('COMMIT');return {id:row.id,email:row.email,operation:row.operation,input:JSON.parse(row.input),runId};
|
||||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||||
});
|
||||
}
|
||||
export const heartbeatAiJob=(id,runId)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET heartbeat=? WHERE id=? AND run_id=? AND state='running'").run(Date.now(),id,runId).changes>0);
|
||||
export const finishAiJob=(id,runId,result,error=null)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET state=?,result=?,error=?,updated_at=? WHERE id=? AND run_id=? AND state='running'")
|
||||
.run(error?'failed':'completed',error?null:JSON.stringify(result),error,new Date().toISOString(),id,runId).changes>0);
|
||||
export const cancelAiJob=(email,id)=>dbWork(db=>db.prepare("UPDATE ai_jobs SET state='cancelled',error=NULL,updated_at=? WHERE id=? AND email=? AND state IN ('queued','running','interrupted','failed')").run(new Date().toISOString(),id,email).changes>0);
|
||||
export const retryAiJob=(email,id)=>dbWork(db=>{
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const active=db.prepare("SELECT COUNT(*) n FROM ai_jobs WHERE email=? AND state IN ('queued','running')").get(email).n;
|
||||
if(active>=5)throw new Error('En fazla beş etkin AI işi olabilir.');
|
||||
const changed=db.prepare("UPDATE ai_jobs SET state='queued',error=NULL,result=NULL,run_id=NULL,updated_at=? WHERE id=? AND email=? AND state IN ('interrupted','failed')").run(new Date().toISOString(),id,email).changes>0;
|
||||
db.exec('COMMIT');return changed;
|
||||
}catch(error){db.exec('ROLLBACK');throw error;}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as aiJobs from './ai-job-store.mjs';
|
||||
import { startAiJobRunner } from './ai-job-runner.mjs';
|
||||
import { observeTenders, tenderHistory } from './tender-history.mjs';
|
||||
import { requestLifetime } from './request-lifetime.mjs';
|
||||
import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
|
||||
@@ -684,9 +686,42 @@ async function tendersPayload(force = false) {
|
||||
};
|
||||
}
|
||||
|
||||
const aiJobRunner = startAiJobRunner(async (job,{signal}) => {
|
||||
if(!aiConfigured())throw new Error('AI hizmeti kullanılamıyor. Ayarlar düzeltildikten sonra yeniden başlatın.');
|
||||
const body=job.input;
|
||||
const shared={signal,ikn:body.ikn,tender:body.tender,chain:body.chain,text:body.text};
|
||||
const result=job.operation==='belge-sor' ? await belgeyeSor({...shared,soru:body.soru})
|
||||
: job.operation==='teklif-denetim' ? await teklifDenetle({...shared,taslak:body.taslak,gereklilikler:body.gereklilikler})
|
||||
: await teklifTaslak({...shared,tur:body.tur,firma:body.firma,belgeler:body.belgeler,fiyat:body.fiyat,gereklilikler:body.gereklilikler});
|
||||
return {success:true,...result};
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
try {
|
||||
if (url.pathname === '/api/me/ai-jobs') {
|
||||
const user=userFromAuthHeader(req.headers.authorization);
|
||||
if(!user)return json(res,401,{success:false,error:'Oturum gerekli.'});
|
||||
const id=url.searchParams.get('id');
|
||||
if(req.method==='GET') {
|
||||
if(id){const job=aiJobs.getAiJob(user.email,id);return json(res,job?200:404,{success:!!job,job,error:job?undefined:'İş bulunamadı.'});}
|
||||
return json(res,200,{success:true,jobs:aiJobs.listAiJobs(user.email,url.searchParams.get('ikn')).map(({result,...job})=>job)});
|
||||
}
|
||||
if(req.method==='DELETE') {
|
||||
const ok=aiJobs.cancelAiJob(user.email,id);if(ok)aiJobRunner.cancel(id);
|
||||
return json(res,ok?200:404,{success:ok,error:ok?undefined:'İş bulunamadı veya artık iptal edilemiyor.'});
|
||||
}
|
||||
if(req.method==='POST') {
|
||||
try {
|
||||
if(!aiConfigured())return json(res,503,{success:false,error:'AI hizmeti şu anda kullanılamıyor. Daha sonra yeniden deneyin.'});
|
||||
const body=await readBody(req,2*1024*1024);
|
||||
if(id) {if(body.action!=='retry'||!aiJobs.retryAiJob(user.email,id))return json(res,404,{success:false,error:'Yeniden başlatılabilir iş bulunamadı.'});}
|
||||
const job=id?aiJobs.getAiJob(user.email,id):aiJobs.createAiJob(user.email,body.requestId,body.operation,body.input);
|
||||
aiJobRunner.wake();return json(res,202,{success:true,job});
|
||||
}catch(error){return json(res,400,{success:false,error:error.message});}
|
||||
}
|
||||
return json(res,405,{success:false,error:'Desteklenmeyen metot.'});
|
||||
}
|
||||
if (url.pathname === '/api/exchange-rates') {
|
||||
try { return json(res,200,{success:true,...await exchangeRates()}); }
|
||||
catch(e) { return json(res,503,{success:false,error:e.message}); }
|
||||
@@ -1605,3 +1640,5 @@ arkaPlandaIsit();
|
||||
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
|
||||
startScheduler({ tenderGetir: () => tendersPayload() });
|
||||
});
|
||||
|
||||
server.on('close',()=>aiJobRunner.stop());
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 [jobs,setJobs]=useState([]),[error,setError]=useState(null),[busy,setBusy]=useState(false);
|
||||
const alive=useRef(true);
|
||||
useEffect(()=>{alive.current=true;return()=>{alive.current=false;};},[]);
|
||||
const act=async fn=>{
|
||||
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]);
|
||||
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>
|
||||
<button onClick={()=>act(refresh)} disabled={busy} className="border rounded px-3 py-2 mt-2">İş listesini yenile</button>
|
||||
{busy&&<p role="status">İş bilgileri alınıyor…</p>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
{!jobs.length&&!busy&&!error&&<p>Bu ihale için kayıtlı AI işi yok.</p>}
|
||||
<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>}
|
||||
{['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>
|
||||
</details>;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AiJobHistory } from './AiJobHistory';
|
||||
import { validateOfferResponse } from '../services/offerResponse';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react';
|
||||
import { getToken } from '../services/auth';
|
||||
@@ -30,7 +32,7 @@ const OfferAssistantSession = ({ tender }) => {
|
||||
const lifetime = useRef(new AbortController());
|
||||
useEffect(() => { live.current = true; lifetime.current = new AbortController(); return () => { live.current = false; lifetime.current.abort(); }; }, []);
|
||||
const current = () => live.current && Boolean(session.current) && getToken() === session.current;
|
||||
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents, signal:lifetime.current.signal});
|
||||
const post = (url, body, documents) => requestOfferAssistant({url, body, readToken:getToken, isCurrent:current, listDocuments:documents, signal:lifetime.current.signal,background:true,onProgress:job=>{if(current())setJobProgress(job);}});
|
||||
const [arac, setArac] = useState('sor');
|
||||
const [metin, setMetin] = useState(''); // isteğe bağlı: yapıştırılan doküman metni
|
||||
const [sourceName, setSourceName] = useState('');
|
||||
@@ -44,13 +46,26 @@ const OfferAssistantSession = ({ tender }) => {
|
||||
const [draftSource, setDraftSource] = useState(null);
|
||||
const [mesgul, setMesgul] = useState(false);
|
||||
const [hata, setHata] = useState(null);
|
||||
const [jobProgress,setJobProgress]=useState(null);
|
||||
const [jobRevision,setJobRevision]=useState(0);
|
||||
const openJob=job=>{
|
||||
if(!current() || mesgul)return;
|
||||
if(job?.ikn!==tender.ikn || job?.state!=='completed')throw new Error('Tamamlanmış iş bu ihaleye ait değil.');
|
||||
const result=validateOfferResponse('/api/ai/'+job.operation,job.result);
|
||||
const input=job.input || {};
|
||||
setMetin(input.text || '');setSourceName(input.text?'Kayıtlı AI işi kaynağı':'');
|
||||
setCevap(null);setDenetim(null);setBolumler([]);setDraftReview(null);
|
||||
if(job.operation==='belge-sor'){setArac('sor');setSoru(input.soru || '');setCevap(result);}
|
||||
if(job.operation==='teklif-denetim'){setArac('denet');setTaslak(input.taslak || '');setDenetim(result);}
|
||||
if(job.operation==='teklif-taslak'){setArac('uret');setTur(input.tur || 'tam');setBolumler(result.bolumler);setDraftReview(result.draftReview);setDraftSource(input.text || '');}
|
||||
};
|
||||
|
||||
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); }
|
||||
finally { busy.current = false; if (current()) {setMesgul(false);setJobRevision(n=>n+1);} }
|
||||
};
|
||||
const sor = (q) => calistir(async () => {
|
||||
const result = await post('/api/ai/belge-sor', {ikn:tender.ikn, soru:q, text:metin || undefined, tender});
|
||||
@@ -96,6 +111,8 @@ const OfferAssistantSession = ({ tender }) => {
|
||||
</div>
|
||||
{metin && <button type="button" disabled={mesgul} className="text-xs text-blue-700 underline" onClick={() => {setMetin('');setSourceName('');setCevap(null);setDenetim(null);}}>Yüklenen kaynağı kaldır ve ilan kaynağını kullan</button>}
|
||||
<p className="text-[11px] text-slate-500">Metin sağlanmazsa sunucu EKAP dokümanını / ilan metnini kullanır. Yanıtların kaynak parçalarını kontrol edin; AI yanıtları hata veya eksik bilgi içerebilir.</p>
|
||||
<AiJobHistory ikn={tender.ikn} onOpen={openJob} revision={jobRevision} locked={mesgul} />
|
||||
{mesgul && jobProgress && <p role="status" className="text-xs text-blue-800">{jobProgress.state==='queued'?'AI işi sırada.':'AI işi sunucuda işleniyor.'} Ekranı kapatırsanız sonucu Kayıtlı AI işleri bölümünden açabilirsiniz.</p>}
|
||||
{hata && <p role="alert" className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg p-2">{hata}</p>}
|
||||
|
||||
{arac === 'sor' && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {accountRequest} from './accountRequest.js';
|
||||
import {awaitWithAbort} from './abortable.js';
|
||||
export const listAiJobs=ikn=>accountRequest('/api/me/ai-jobs?'+new URLSearchParams({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'})});
|
||||
export async function runDurableAiJob(operation,input,{signal,check,onProgress=()=>{},request=accountRequest,pollMs=1500}={}) {
|
||||
check();
|
||||
const started=await request('/api/me/ai-jobs',{method:'POST',signal,body:JSON.stringify({operation,input,requestId:crypto.randomUUID()})});
|
||||
let job=started.job;
|
||||
while(true) {
|
||||
check();
|
||||
if(!job || !['queued','running','completed','failed','interrupted','cancelled'].includes(job.state))throw new Error('AI iş durumu geçersiz. Kayıtlı işler bölümünden kontrol edin.');
|
||||
onProgress(job);
|
||||
if(job.state==='completed')return job.result;
|
||||
if(['failed','interrupted','cancelled'].includes(job.state))throw new Error(job.error || 'AI işi iptal edildi.');
|
||||
let timer;
|
||||
try {await awaitWithAbort(new Promise(resolve=>{timer=setTimeout(resolve,pollMs);}),signal,()=>clearTimeout(timer));}
|
||||
finally {clearTimeout(timer);}
|
||||
check();
|
||||
job=(await request('/api/me/ai-jobs?'+new URLSearchParams({id:job.id}),{signal})).job;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { runDurableAiJob } from './aiJobs.js';
|
||||
import { validateOfferResponse } from './offerResponse.js';
|
||||
/** 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, timeoutMs = 120000, signal}) {
|
||||
export async function requestOfferAssistant({url, body, readToken, isCurrent = () => true, listDocuments, fetcher = fetch, timeoutMs = 120000, signal, background = false, onProgress}) {
|
||||
const token = readToken();
|
||||
const check = () => {
|
||||
signal?.throwIfAborted();
|
||||
@@ -19,6 +20,10 @@ export async function requestOfferAssistant({url, body, readToken, isCurrent = (
|
||||
.map(b => ({ad:b.ad, kategori:b.kategori}))};
|
||||
}
|
||||
check();
|
||||
if(background) {
|
||||
const data=await runDurableAiJob(url.split('/').at(-1),payload,{signal,check,onProgress});
|
||||
check();return validateOfferResponse(url,data);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const cancel = () => controller.abort();
|
||||
signal?.addEventListener('abort',cancel,{once:true});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {runDurableAiJob} from '../src/services/aiJobs.js';
|
||||
test('durable client polls separate requests until completion and creates a job only once',async()=>{
|
||||
let calls=0;const states=[];
|
||||
const result=await runDurableAiJob('teklif-taslak',{ikn:'2026/1'},{check:()=>{},pollMs:1,onProgress:job=>states.push(job.state),request:async(url,options)=>{
|
||||
calls++;
|
||||
if(calls===1){assert.equal(options.method,'POST');assert.ok(JSON.parse(options.body).requestId);return {job:{id:'a',state:'queued'}};}
|
||||
assert.ok(url.includes('id=a'));assert.equal(options.method,undefined);
|
||||
return calls===2?{job:{id:'a',state:'running'}}:{job:{id:'a',state:'completed',result:{success:true}}};
|
||||
}});
|
||||
assert.deepEqual(states,['queued','running','completed']);assert.equal(calls,3);assert.equal(result.success,true);
|
||||
});
|
||||
test('closing the viewer stops polling without deleting a durable job',async()=>{
|
||||
const controller=new AbortController();let calls=0;
|
||||
await assert.rejects(runDurableAiJob('belge-sor',{ikn:'2026/1'},{signal:controller.signal,check:()=>controller.signal.throwIfAborted(),pollMs:1,onProgress:()=>controller.abort(),request:async()=>{calls++;return {job:{id:'a',state:'running'}};}}),{name:'AbortError'});
|
||||
assert.equal(calls,1);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test,{after} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
const dir=mkdtempSync(join(tmpdir(),'ihaleist-jobs-'));process.env.IHALEIST_USER_DATA_DIR=dir;
|
||||
const jobs=await import('../server/ai-job-store.mjs');
|
||||
after(()=>rmSync(dir,{recursive:true,force:true}));
|
||||
test('durable jobs are idempotent and account scoped',()=>{
|
||||
const first=jobs.createAiJob('a','request-1','teklif-denetim',{ikn:'2026/1',taslak:'private'});
|
||||
assert.equal(jobs.createAiJob('a','request-1','teklif-denetim',{ikn:'2026/1',taslak:'private'}).id,first.id);
|
||||
assert.throws(()=>jobs.createAiJob('a','request-1','teklif-denetim',{taslak:'other'}),/farklı kaynak/);
|
||||
assert.equal(jobs.getAiJob('b',first.id),null);assert.deepEqual(jobs.listAiJobs('b'),[]);
|
||||
assert.equal(jobs.cancelAiJob('b',first.id),false);
|
||||
const claimed=jobs.claimAiJob();assert.equal(claimed.input.taslak,'private');
|
||||
assert.equal(jobs.finishAiJob(first.id,'wrong',{success:true}),false);
|
||||
assert.equal(jobs.finishAiJob(first.id,claimed.runId,{success:true,answer:'done'}),true);
|
||||
assert.equal(jobs.getAiJob('a',first.id).result.answer,'done');
|
||||
assert.equal(jobs.getAiJob('a',first.id).input.taslak,'private');
|
||||
assert.equal(jobs.listAiJobs('a')[0].input,undefined);
|
||||
});
|
||||
test('lost workers become interrupted without automatic provider replay; retry and cancellation reject late results',()=>{
|
||||
const first=jobs.createAiJob('a','request-2','teklif-taslak',{ikn:'2026/2'});
|
||||
const claimed=jobs.claimAiJob(Date.now()-120000);
|
||||
assert.equal(jobs.claimAiJob(),null);
|
||||
assert.equal(jobs.getAiJob('a',first.id).state,'interrupted');
|
||||
assert.equal(jobs.finishAiJob(first.id,claimed.runId,{success:true}),false);
|
||||
assert.equal(jobs.retryAiJob('a',first.id),true);
|
||||
const restarted=jobs.claimAiJob();assert.notEqual(restarted.runId,claimed.runId);
|
||||
assert.equal(jobs.cancelAiJob('a',first.id),true);
|
||||
assert.equal(jobs.heartbeatAiJob(first.id,restarted.runId),false);
|
||||
assert.equal(jobs.finishAiJob(first.id,restarted.runId,{success:true}),false);
|
||||
assert.equal(jobs.getAiJob('a',first.id).state,'cancelled');
|
||||
});
|
||||
|
||||
test('runner stores results independently of an HTTP connection',async()=>{
|
||||
const {startAiJobRunner}=await import('../server/ai-job-runner.mjs');
|
||||
const job=jobs.createAiJob('runner','request-run','belge-sor',{ikn:'2026/8',soru:'teslim'});
|
||||
let finish;
|
||||
const runner=startAiJobRunner(async()=>new Promise(resolve=>{finish=resolve;}),{intervalMs:100000});
|
||||
try {
|
||||
runner.wake();assert.equal(jobs.getAiJob('runner',job.id).state,'running');
|
||||
finish({success:true,cevap:'Stored answer'});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(jobs.getAiJob('runner',job.id).result.cevap,'Stored answer');
|
||||
} finally {runner.stop();}
|
||||
});
|
||||
@@ -180,3 +180,11 @@ test('source history requires authentication and distinguishes empty results fro
|
||||
assert.equal((await request('/api/tenders/source-history?ikn=2026/123&before=bad')).status,400);
|
||||
assert.equal((await request('/api/tenders/source-history?ikn=2026/123','POST',{})).status,405);
|
||||
});
|
||||
|
||||
test('durable AI job endpoints enforce account isolation without starting provider calls',async()=>{
|
||||
assert.equal((await request('/api/me/ai-jobs','GET',null,null)).status,401);
|
||||
const list=await request('/api/me/ai-jobs?ikn=2026/123');assert.equal(list.status,200);assert.deepEqual(list.data.jobs,[]);
|
||||
assert.equal((await request('/api/me/ai-jobs?id=missing')).status,404);
|
||||
assert.equal((await request('/api/me/ai-jobs?id=missing','DELETE',null,other)).status,404);
|
||||
assert.equal((await request('/api/me/ai-jobs','PUT',{})).status,405);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user