Persist offer assistant jobs and recover results across closed views
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user