pini-print-bot / services /sessionManager.js
dotandru's picture
V5.7.1: Unified Product State Authority - Fixed post-pricing amnesia & enabled multi-product support
1c434d9
Raw
History Blame Contribute Delete
6.56 kB
/**
* services/sessionManager.js
* ืžื ื”ืœ ื”ื–ื™ื›ืจื•ืŸ (In-Memory Session Store)
* =======================================
* ืชืคืงื™ื“: ืœืฉืžื•ืจ ืืช ื”ืขื’ืœื” ื•ืืช ื”ืกื˜ื˜ื•ืก ืฉืœ ื›ืœ ืžืฉืชืžืฉ.
* ื”ืขืจื”: ื‘ื™ื™ืฆื•ืจ ืืžื™ืชื™ ืžื—ืœื™ืคื™ื ืืช ื–ื” ื‘-Redis, ืื‘ืœ ืœืคื™ืชื•ื— ื–ื” ืžืขื•ืœื”.
*/
const sessions = {};
// ื”ื’ื“ืจืช ื–ืžืŸ ืชืคื•ื’ื” ืœืฉื™ื—ื” (30 ื“ืงื•ืช)
const SESSION_TIMEOUT = 30 * 60 * 1000;
// ื›ืžื•ืช ืžืงืกื™ืžืœื™ืช ืฉืœ ืžื–ื”ื™ ื‘ืงืฉื•ืช ืœืฉืžื™ืจื” (ืœืžื ื™ืขืช Memory Leak)
const MAX_PROCESSED_REQUESTS = 50;
// Spec v5.7: Max history items (5 turns = 10 items)
const MAX_HISTORY_ITEMS = 10;
function getSession(userId) {
if (!sessions[userId]) {
console.log(`โœจ New Session created for: ${userId}`);
sessions[userId] = {
id: userId,
cart: [], // ื”ืžื•ืฆืจื™ื ืฉื ื•ืกืคื• ืœืขื’ืœื”
currentProduct: null, // ื”ืžื•ืฆืจ ืฉืขืœื™ื• ืžื“ื‘ืจื™ื ื›ืจื’ืข
active_products: [], // [Spec v5.7.1] ืžื•ื“ืœ ืฉื™ื—ื” ืฉื•ืžืจ ืกื˜ื˜ื•ืก ืฉืœ ืžื•ืฆืจื™ื: draft, priced
processedRequests: new Map(),
lastActive: Date.now(),
lastSpecChangeTime: Date.now(), // Tracking for Semantic Aging
blockState: { reason: null, ttl: 0 }, // Phase 4.2: Built-in Turn-Based Memory
// Spec v5.7: Stateful Advisor Additions
history: [],
lastBotMessage: null
};
}
const session = sessions[userId];
// Spec v5.7: History Management Helper
if (!session.addToHistory) {
session.addToHistory = (role, text) => {
session.history.push({ role, text });
if (session.history.length > MAX_HISTORY_ITEMS) {
session.history.shift();
}
};
}
// Phase 5.3: Semantic Aging (Drift Detection)
const driftDelta = (Date.now() - session.lastSpecChangeTime) / 1000 / 60; // in minutes
if (driftDelta >= 8 && driftDelta <= 12 && session.active_products && session.active_products.length > 0) {
session.driftDetected = true;
console.log(`โณ [SESSION] Semantic Aging detected for ${userId}: ${driftDelta.toFixed(1)}m`);
} else {
session.driftDetected = false;
}
if (session.blockState && session.blockState.reason === "PARAMETER_INSTABILITY") {
if (session.blockState.ttl > 0) {
session.blockState.ttl--;
console.log(`[SESSION] TTL Decrement: ${session.blockState.ttl} left for ${userId}`);
if (session.blockState.ttl === 0) {
console.log(`[SESSION] TTL Expired. Releasing block for ${userId}`);
session.blockState.reason = null;
}
}
}
// ืขื“ื›ื•ืŸ ื–ืžืŸ ืคืขื™ืœื•ืช ืื—ืจื•ืŸ
sessions[userId].lastActive = Date.now();
return sessions[userId];
}
/**
* Phase 1.2: Idempotency Guard (Check & Lock)
* ื‘ื•ื“ืง ืื ื”ื‘ืงืฉื” ื›ื‘ืจ ืงื™ื™ืžืช/ืžืขื•ื‘ื“ืช, ื•ื ื•ืขืœ ืื•ืชื” ื‘ืื•ืคืŸ ืกื™ื ื›ืจื•ื ื™
*/
function checkAndLockRequest(session, requestId, payloadText) {
if (!requestId) return { status: 'NEW' };
const existing = session.processedRequests.get(requestId);
if (existing) {
if (existing.payload !== payloadText) {
console.warn(`๐Ÿšจ SECURITY ALERT: Payload Mismatch for requestId ${requestId}`);
return { status: 'MISMATCH_ERROR' };
}
if (existing.status === 'PROCESSING') {
return { status: 'PROCESSING_ERROR' };
}
if (existing.status === 'COMPLETED') {
console.log(`โ™ป๏ธ IDEMPOTENCY: Returning cached response for ${requestId}`);
return { status: 'COMPLETED', cachedResponse: existing.response };
}
}
session.processedRequests.set(requestId, {
payload: payloadText,
status: 'PROCESSING',
timestamp: Date.now(),
response: null
});
if (session.processedRequests.size > MAX_PROCESSED_REQUESTS) {
const oldestKey = session.processedRequests.keys().next().value;
session.processedRequests.delete(oldestKey);
}
return { status: 'NEW' };
}
function cacheCompletedRequest(session, requestId, responseBody) {
if (!requestId) return;
const reqData = session.processedRequests.get(requestId);
if (reqData && reqData.status === 'PROCESSING') {
reqData.status = 'COMPLETED';
reqData.response = JSON.parse(JSON.stringify(responseBody));
}
}
function releaseFailedRequest(session, requestId) {
if (!requestId) return;
const reqData = session.processedRequests.get(requestId);
if (reqData && reqData.status === 'PROCESSING') {
session.processedRequests.delete(requestId);
console.log(`๐Ÿ”“ RELEASED Failed Request Lock: ${requestId}`);
}
}
function clearSession(userId) {
if (sessions[userId]) {
sessions[userId].currentProduct = null;
sessions[userId].active_products = [];
sessions[userId].history = [];
sessions[userId].lastBotMessage = null;
console.log(`๐Ÿงน Session context cleared for: ${userId}`);
}
}
function clearCart(userId) {
if (sessions[userId]) {
sessions[userId].cart = [];
sessions[userId].currentProduct = null;
sessions[userId].active_products = [];
sessions[userId].history = [];
sessions[userId].lastBotMessage = null;
console.log(`๐Ÿ—‘๏ธ Cart emptied for: ${userId}`);
}
}
/**
* Phase 4: Secure Push to Cart
*/
function pushToSessionCart(session, item) {
if (!item.traceId) {
throw new Error("Cannot push item without traceId to cart.");
}
const exists = session.cart.some(cartItem => cartItem.traceId === item.traceId);
if (exists) {
console.warn(`๐Ÿ›ก๏ธ [SESSION] Duplicate traceId blocked: ${item.traceId}`);
return false;
}
session.cart.push(item);
console.log(`โœ… [SESSION] Item added to cart: ${item.product} (${item.traceId.substring(0, 8)}...)`);
return true;
}
// ืžื ื’ื ื•ืŸ ื ื™ืงื•ื™ ืื•ื˜ื•ืžื˜ื™ ืœื–ื™ื›ืจื•ืŸ (Garbage Collection)
setInterval(() => {
const now = Date.now();
Object.keys(sessions).forEach(key => {
if (now - sessions[key].lastActive > SESSION_TIMEOUT) {
delete sessions[key];
}
});
}, 60 * 1000);
module.exports = {
getSession,
clearSession,
clearCart,
pushToSessionCart,
checkAndLockRequest,
cacheCompletedRequest,
releaseFailedRequest
};