Persist offer assistant jobs and recover results across closed views

This commit is contained in:
2026-09-06 12:35:30 +03:00
parent 2e16a68565
commit f830625b60
12 changed files with 297 additions and 3 deletions
+10
View File
@@ -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.
+2
View File
@@ -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.
+26
View File
@@ -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();}};
}
+71
View File
@@ -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;}
});
+37
View File
@@ -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());
+30
View File
@@ -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>;
}
+19 -2
View File
@@ -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' && (
+23
View File
@@ -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;
}
}
+6 -1
View File
@@ -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});
+18
View File
@@ -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);
});
+47
View File
@@ -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();}
});
+8
View File
@@ -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);
});