40018-vm/server.js
2026-05-16 08:34:43 +00:00

1596 lines
71 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = Number(process.env.PORT) || 3001;
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_MODEL = 'gemini-2.5-flash-lite';
const GEMINI_URL = GEMINI_API_KEY
? `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(GEMINI_API_KEY)}`
: '';
const STORAGE_FILE = path.join(__dirname, 'db', 'sessions.json');
const DEFAULT_PLANNING = { developers: 3, sprintWeeks: 2, hoursPerDay: 8 };
const STAGES = new Set(['intro', 'exploration', 'usecase', 'evaluation', 'done']);
const STAGE_LABELS = {
intro: 'التمهيد',
exploration: 'الاستكشاف',
usecase: 'صياغة حالات الاستخدام',
evaluation: 'التقييم',
done: 'الختام',
};
const KNOWN_ACTORS = [
'مسؤول النظام', 'المشرف', 'المدير', 'الموظف', 'المستخدم', 'المستخدم النهائي',
'العميل', 'الزبون', 'الطالب', 'الدكتور', 'المعلم', 'المحاسب', 'المراجع',
'الفني', 'المندوب', 'السائق', 'أمين المستودع', 'السكرتير', 'ولي الأمر',
'موظف الدعم', 'موظف الاستقبال', 'بوابة الدفع', 'نظام خارجي', 'API',
'قاعدة البيانات', 'مزود الرسائل', 'خدمة بريد', 'البنك', 'شركة الشحن'
];
const sessions = loadSessions();
const CHAT_SYSTEM_PROMPT = `أنت "محلل نظم ذكي" خبير في هندسة البرمجيات. مهمتك إجراء مقابلة تفاعلية باللغة العربية الفصحى لاستخلاص متطلبات نظام برمجي وتحويلها إلى عناصر قابلة للتقدير.
المطلوب منك في كل رد:
1) اسأل سؤالاً واحداً فقط، قصيراً وواضحاً.
2) حدّث دائماً القوائم الكاملة التالية: Actors, Function Points, Use Cases, Inputs/Outputs.
3) إذا قدّم المستخدم تعديلاً أو تصحيحاً فادمجه في القوائم بدل تجاهله.
4) عند اكتمال الصورة الأساسية، انتقل إلى evaluation ثم done.
5) لا تُخرج أي نص خارج JSON.
أعد JSON صالحاً فقط بالشكل التالي:
{
"reply": "سؤال عربي واحد أو ملخص قصير مع سؤال",
"stage": "intro|exploration|usecase|evaluation|done",
"scope": "وصف موجز لنطاق المشروع",
"actors": ["Actor 1", "Actor 2"],
"functionPoints": [
{ "id": "FP-01", "name": "اسم الوظيفة", "description": "وصف موجز", "complexity": "بسيطة|متوسطة|معقدة", "fpScore": 3 }
],
"useCases": [
{ "id": "UC-01", "title": "عنوان حالة الاستخدام", "actor": "الفاعل", "preconditions": "...", "mainFlow": ["خطوة 1", "خطوة 2"], "alternateFlow": ["..."] }
],
"inputOutputs": [
{ "id": "IO-01", "type": "input|output", "name": "اسم العنصر", "source": "المصدر", "destination": "الوجهة", "description": "وصف موجز" }
],
"isComplete": false
}
قواعد إضافية:
- إذا كانت القوائم الحالية جيدة لكن ينقصها تفصيل، حافظ عليها وأضف التفاصيل فقط.
- استخدم العربية الفصحى، ولا تُكثر من الشرح.
- قيم fpScore هكذا: بسيطة=3، متوسطة=4، معقدة=6.
- أعد دائماً القوائم كاملةً بعد التحديث.`;
const SRS_ANALYSIS_PROMPT = `أنت محلل متطلبات برمجية. اقرأ نص SRS خاماً واستخرج منه:
- summary
- actors
- useCases
- inputOutputs
- functionPoints
- question
أعد JSON فقط بالشكل التالي:
{
"summary": "ملخص عربي قصير لنطاق النظام",
"actors": ["Actor 1", "Actor 2"],
"useCases": [
{ "title": "عنوان الحالة", "actor": "الفاعل", "preconditions": "...", "mainFlow": ["خطوة 1", "خطوة 2"], "alternateFlow": ["..."] }
],
"inputOutputs": [
{ "type": "input|output", "name": "اسم العنصر", "source": "المصدر", "destination": "الوجهة", "description": "وصف موجز" }
],
"functionPoints": [
{ "name": "اسم الوظيفة", "description": "وصف موجز", "complexity": "بسيطة|متوسطة|معقدة", "fpScore": 3 }
],
"confidence": "high|medium|low",
"question": "سؤال قصير يطلب من المستخدم تأكيد أو تعديل المقترحات"
}
قواعد:
- استخرج 3 إلى 12 حالة استخدام إن أمكن.
- ركّز على actors الحقيقيين والأنظمة الخارجية.
- إن لم تُذكر inputs/outputs صراحة فاستنتجها بشكل معقول من حالات الاستخدام.
- لا تُخرج أي نص خارج JSON.`;
app.use(express.json({ limit: '6mb' }));
app.use(express.static(path.join(__dirname, 'public')));
function nowIso() {
return new Date().toISOString();
}
function newId() {
return 'sess_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function normalizeText(value) {
return String(value ?? '')
.replace(/\r/g, '')
.replace(/\u0000/g, '')
.trim();
}
function compactWhitespace(value) {
return normalizeText(value)
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function cleanSentence(value) {
return compactWhitespace(value)
.replace(/^[-*•\d\.)\s]+/, '')
.replace(/[؛،,]+$/, '')
.trim();
}
function clampInt(value, min, max, fallback) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.min(max, Math.max(min, Math.round(num)));
}
function normalizePlanning(input) {
return {
developers: clampInt(input?.developers, 1, 20, DEFAULT_PLANNING.developers),
sprintWeeks: clampInt(input?.sprintWeeks, 1, 6, DEFAULT_PLANNING.sprintWeeks),
hoursPerDay: clampInt(input?.hoursPerDay, 4, 10, DEFAULT_PLANNING.hoursPerDay),
};
}
function uniqueStrings(list) {
const seen = new Set();
const items = [];
for (const raw of Array.isArray(list) ? list : []) {
const text = compactWhitespace(raw);
const key = text.toLowerCase();
if (!text || seen.has(key)) continue;
seen.add(key);
items.push(text);
}
return items;
}
function dedupeBy(list, keyFn) {
const out = [];
const seen = new Set();
for (const item of Array.isArray(list) ? list : []) {
const key = String(keyFn(item) ?? '').toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function stageLabel(stage) {
return STAGE_LABELS[stage] || stage || 'الاستكشاف';
}
function normalizeComplexity(value) {
const raw = compactWhitespace(value).toLowerCase();
if (/complex|معقد/.test(raw)) return 'معقدة';
if (/medium|متوسط/.test(raw)) return 'متوسطة';
return 'بسيطة';
}
function complexityScore(level) {
const complexity = normalizeComplexity(level);
if (complexity === 'معقدة') return 6;
if (complexity === 'متوسطة') return 4;
return 3;
}
function defaultMainFlow(title, actor = 'المستخدم') {
return [
`يفتح ${actor} مسار ${title}.`,
'يدخل البيانات أو يحدد الخيارات المطلوبة.',
'يعالج النظام الطلب ويعرض النتيجة أو التأكيد المناسب.',
];
}
function normalizeActors(list) {
return uniqueStrings(
(Array.isArray(list) ? list : []).map((item) => typeof item === 'string' ? item : item?.name || item?.title || '')
).slice(0, 20);
}
function normalizeUseCases(list) {
const useCases = (Array.isArray(list) ? list : []).map((item, index) => {
const raw = typeof item === 'string' ? { title: item } : (item || {});
const title = cleanSentence(raw.title || raw.name || `حالة استخدام ${index + 1}`);
if (!title) return null;
const actor = cleanSentence(raw.actor || 'المستخدم') || 'المستخدم';
const mainFlow = uniqueStrings(Array.isArray(raw.mainFlow) ? raw.mainFlow.map(cleanSentence) : []);
const alternateFlow = uniqueStrings(Array.isArray(raw.alternateFlow) ? raw.alternateFlow.map(cleanSentence) : []);
return {
id: cleanSentence(raw.id || `UC-${String(index + 1).padStart(2, '0')}`),
title,
actor,
preconditions: cleanSentence(raw.preconditions || `يملك ${actor} الصلاحيات اللازمة.`),
mainFlow: mainFlow.length ? mainFlow : defaultMainFlow(title, actor),
alternateFlow,
};
}).filter(Boolean);
return dedupeBy(useCases, (item) => item.title).map((item, index) => ({
...item,
id: item.id || `UC-${String(index + 1).padStart(2, '0')}`,
}));
}
function normalizeInputOutputs(list) {
const items = (Array.isArray(list) ? list : []).map((item, index) => {
let raw = item || {};
if (typeof raw === 'string') {
const parts = raw.split('|').map((part) => cleanSentence(part));
raw = {
type: parts[0],
name: parts[1],
source: parts[2],
destination: parts[3],
description: parts[4],
};
}
const typeRaw = cleanSentence(raw.type || raw.ioType || raw.direction || 'input').toLowerCase();
const type = /output|out|مخرج|إخراج/.test(typeRaw) ? 'output' : 'input';
const name = cleanSentence(raw.name || raw.title || raw.label || `${type === 'input' ? 'مدخل' : 'مخرج'} ${index + 1}`);
if (!name) return null;
return {
id: cleanSentence(raw.id || `IO-${String(index + 1).padStart(2, '0')}`),
type,
name,
source: cleanSentence(raw.source || (type === 'input' ? 'المستخدم' : 'النظام')) || (type === 'input' ? 'المستخدم' : 'النظام'),
destination: cleanSentence(raw.destination || (type === 'input' ? 'النظام' : 'المستخدم')) || (type === 'input' ? 'النظام' : 'المستخدم'),
description: cleanSentence(raw.description || ''),
};
}).filter(Boolean);
return dedupeBy(items, (item) => `${item.type}:${item.name}`).map((item, index) => ({
...item,
id: `IO-${String(index + 1).padStart(2, '0')}`,
}));
}
function normalizeFunctionPoints(list) {
const items = (Array.isArray(list) ? list : []).map((item, index) => {
const raw = typeof item === 'string' ? { name: item } : (item || {});
const name = cleanSentence(raw.name || raw.title || `وظيفة ${index + 1}`);
if (!name) return null;
const complexity = normalizeComplexity(raw.complexity);
const score = Number(raw.fpScore);
return {
id: cleanSentence(raw.id || `FP-${String(index + 1).padStart(2, '0')}`),
name,
description: cleanSentence(raw.description || `وظيفة تدعم ${name}.`),
complexity,
fpScore: Number.isFinite(score) && score > 0 ? score : complexityScore(complexity),
};
}).filter(Boolean);
return dedupeBy(items, (item) => item.name).map((item, index) => ({
...item,
id: `FP-${String(index + 1).padStart(2, '0')}`,
}));
}
function uniqueActorsFromUseCases(useCases) {
return normalizeActors((useCases || []).map((uc) => uc.actor));
}
function isExternalActor(name) {
return /api|نظام خارجي|external|service|خدمة|بوابة الدفع|bank|بنك|gateway|شركة الشحن|مزود|provider|رسائل|email|بريد|قاعدة البيانات/i.test(String(name || ''));
}
function deriveInputOutputsFromUseCases(useCases) {
const items = [];
(useCases || []).forEach((uc) => {
const actor = uc.actor || 'المستخدم';
items.push({
type: 'input',
name: `بيانات ${uc.title}`,
source: actor,
destination: 'النظام',
description: `المعطيات التي يحتاجها النظام لتنفيذ ${uc.title}.`,
});
items.push({
type: 'output',
name: `نتيجة ${uc.title}`,
source: 'النظام',
destination: actor,
description: `المخرجات أو الإشعارات الناتجة عن ${uc.title}.`,
});
});
return normalizeInputOutputs(items);
}
function buildFunctionPointsFromArtifacts(useCases, inputOutputs) {
const normalizedUseCases = normalizeUseCases(useCases);
const normalizedIO = normalizeInputOutputs(inputOutputs);
const useCasePoints = normalizedUseCases.map((uc) => {
const steps = (uc.mainFlow?.length || 0) + (uc.alternateFlow?.length || 0);
const relatedIO = normalizedIO.filter((item) => item.name.includes(uc.title) || uc.title.includes(item.name));
const richness = steps + relatedIO.length;
let complexity = 'بسيطة';
if (richness >= 7 || /تقرير|اعتماد|استيراد|تصدير|تكامل|إشعار|موافقة/.test(uc.title)) complexity = 'معقدة';
else if (richness >= 4) complexity = 'متوسطة';
return {
name: uc.title,
description: `تغطي حالة الاستخدام "${uc.title}" للفاعل ${uc.actor}.`,
complexity,
fpScore: complexityScore(complexity),
};
});
if (useCasePoints.length) {
return normalizeFunctionPoints(useCasePoints);
}
const ioPoints = normalizedIO.map((item) => {
const complexity = item.type === 'output' && /تقرير|لوحة|dashboard|كشف|ملخص|إشعار/.test(item.name)
? 'متوسطة'
: 'بسيطة';
return {
name: item.name,
description: item.description || `${item.type === 'input' ? 'استقبال' : 'إخراج'} ${item.name}.`,
complexity,
fpScore: complexityScore(complexity),
};
});
return normalizeFunctionPoints(ioPoints);
}
function mergeUseCases(existing, incoming) {
const current = normalizeUseCases(existing);
const updates = normalizeUseCases(incoming);
const map = new Map();
current.forEach((item) => map.set(item.title.toLowerCase(), item));
updates.forEach((item) => {
const key = item.title.toLowerCase();
if (!map.has(key)) {
map.set(key, item);
return;
}
const prev = map.get(key);
map.set(key, {
...prev,
...item,
mainFlow: item.mainFlow?.length ? item.mainFlow : prev.mainFlow,
alternateFlow: item.alternateFlow?.length ? uniqueStrings([...(prev.alternateFlow || []), ...item.alternateFlow]) : prev.alternateFlow,
});
});
return normalizeUseCases([...map.values()]);
}
function mergeInputOutputs(existing, incoming) {
const current = normalizeInputOutputs(existing);
const updates = normalizeInputOutputs(incoming);
const map = new Map();
current.forEach((item) => map.set(`${item.type}:${item.name}`.toLowerCase(), item));
updates.forEach((item) => {
const key = `${item.type}:${item.name}`.toLowerCase();
map.set(key, map.has(key) ? { ...map.get(key), ...item } : item);
});
return normalizeInputOutputs([...map.values()]);
}
function mergeFunctionPoints(existing, incoming) {
const current = normalizeFunctionPoints(existing);
const updates = normalizeFunctionPoints(incoming);
const map = new Map();
current.forEach((item) => map.set(item.name.toLowerCase(), item));
updates.forEach((item) => {
const key = item.name.toLowerCase();
map.set(key, map.has(key) ? { ...map.get(key), ...item } : item);
});
return normalizeFunctionPoints([...map.values()]);
}
function inferStage(session) {
if (session.isComplete) return 'done';
if (!session.scope) return 'intro';
if (session.needsStructureConfirmation) return 'exploration';
if (!session.actors.length || !session.useCases.length) return 'exploration';
if (!session.inputOutputs.length) return 'usecase';
if (!session.functionPoints.length) return 'evaluation';
if (session.useCases.length >= 3 && session.functionPoints.length >= 3) return 'evaluation';
return 'usecase';
}
function buildNextQuestion(session) {
if (session.needsStructureConfirmation) {
return 'راجِع القوائم المقترحة ثم أخبرني: هل تعتمدها كما هي أم تريد تعديلها؟';
}
if (!session.scope) {
return 'ما المشكلة الأساسية التي يحلها النظام، وما الهدف التجاري الرئيسي منه؟';
}
if (!session.actors.length) {
return 'من هم المستخدمون الرئيسيون أو الأنظمة الخارجية التي ستتفاعل مع هذا النظام؟';
}
if (session.useCases.length < 3) {
return 'ما أهم العمليات أو الرحلات التي يجب أن يدعمها النظام للمستخدمين؟';
}
if (session.inputOutputs.length < 4) {
return 'ما أهم المدخلات والمخرجات أو التقارير أو الإشعارات التي يتعامل معها النظام؟';
}
const ucWithoutDetail = (session.useCases || []).find((uc) => !Array.isArray(uc.mainFlow) || uc.mainFlow.length < 3);
if (ucWithoutDetail) {
return `ما الخطوات الرئيسية أو الاستثنائية في حالة الاستخدام "${ucWithoutDetail.title}`;
}
if (!session.functionPoints.length) {
return 'هل توجد عمليات معقدة مثل التكاملات الخارجية أو التقارير المركبة أو استيراد/تصدير البيانات؟';
}
return 'أصبح لدينا أساس جيد للتقدير؛ هل تريد تثبيت التقدير الحالي وتوليد التقرير أم إضافة تفاصيل تقنية أخرى؟';
}
function summarizeText(text, maxLen = 280) {
const cleaned = compactWhitespace(text);
if (!cleaned) return '';
const sentences = cleaned
.split(/(?<=[\.\!\؟\?])\s+/)
.map(cleanSentence)
.filter(Boolean);
const summary = sentences.slice(0, 3).join(' ');
return summary.length > maxLen ? `${summary.slice(0, maxLen - 1)}` : summary;
}
function titleFromSentence(sentence) {
let title = cleanSentence(sentence);
title = title
.replace(/^(?:يمكن|يستطيع|يسمح|ينبغي|يجب)\s+(?:ل(?:ل)?[^ ]+\s+)?/, '')
.replace(/^يقوم\s+[^ ]+(?:\s+[^ ]+)?\s+ب/, '')
.replace(/^النظام\s+(?:يقوم|يستطيع|يعرض|يولد|يرسل|يصدر)\s+/, '')
.replace(/^المستخدم\s+/, '')
.replace(/[\.:؛،].*$/, '')
.trim();
return title;
}
function guessActor(snippet, actors) {
const text = String(snippet || '');
const actorFromKnown = [...normalizeActors(actors), ...KNOWN_ACTORS].find((actor) => actor && text.includes(actor));
if (actorFromKnown) return actorFromKnown;
if (/بوابة الدفع|الدفع|bank|بنك|نظام خارجي|api|service|خدمة|تكامل/i.test(text)) return 'نظام خارجي';
if (/مدير|مشرف/.test(text)) return 'المدير';
if (/موظف|محاسب|سكرتير|فني/.test(text)) return 'الموظف';
if (/عميل|زبون/.test(text)) return 'العميل';
return actors?.[0] || 'المستخدم';
}
function extractActors(text) {
const content = compactWhitespace(text);
const collected = [];
const sectionMatch = content.match(/(?:Actors?|الممثلون|المستخدمون|الأطراف|الجهات المستفيدة)\s*[:\-]\s*([^\n]+)/i);
if (sectionMatch?.[1]) {
collected.push(...sectionMatch[1].split(/[،,؛|]/).map(cleanSentence));
}
KNOWN_ACTORS.forEach((actor) => {
if (content.includes(actor)) collected.push(actor);
});
const regex = /(?:الفاعل|المستخدم|العميل|المشرف|المدير|الموظف|الطالب|الدكتور|المحاسب|المراجع|الفني|الزائر|المورد|المورّد|ولي الأمر)\s+(?:الرئيسي|النهائي)?/g;
const matches = content.match(regex) || [];
collected.push(...matches.map(cleanSentence));
return normalizeActors(collected);
}
function extractUseCases(text, actors) {
const lines = compactWhitespace(text)
.split(/\n+/)
.map(cleanSentence)
.filter(Boolean);
const sentences = compactWhitespace(text)
.split(/[\.\!\؟\?\n]+/)
.map(cleanSentence)
.filter(Boolean);
const pool = dedupeBy([...lines, ...sentences], (item) => item).slice(0, 80);
const actionRegex = /(تسجيل|إنشاء|إدارة|عرض|بحث|تعديل|حذف|اعتماد|رفع|إرسال|توليد|استيراد|تصدير|متابعة|جدولة|إسناد|دفع|حجز|تحديث|إلغاء|استعلام|مراجعة|توثيق|إصدار|إضافة)/;
const useCases = [];
for (const sentence of pool) {
if (sentence.length < 8) continue;
let title = '';
if (/^[-*•\d\.)]/.test(sentence) || /يمكن|يستطيع|يقوم|النظام/.test(sentence)) {
title = titleFromSentence(sentence);
}
if (!title && actionRegex.test(sentence)) {
const matched = sentence.match(new RegExp(`${actionRegex.source}[^\.\n]{0,90}`));
title = cleanSentence(matched?.[0] || '');
}
if (!title || title.split(' ').length > 12 || title.length < 5) continue;
if (/^على النظام|على المستخدم|يجب|ينبغي/.test(title)) continue;
const actor = guessActor(sentence, actors);
useCases.push({
title,
actor,
preconditions: `تتوفر صلاحية ${actor} والوصول إلى البيانات اللازمة.`,
mainFlow: defaultMainFlow(title, actor),
alternateFlow: [],
});
}
const normalized = normalizeUseCases(useCases).slice(0, 12);
return normalized;
}
function cleanupArtifactName(name) {
return cleanSentence(name)
.replace(/^ال/, 'ال')
.replace(/^(?:بيانات|معلومات|طلب|نموذج|ملف|قائمة|تقرير|إشعار)\s+/, (prefix) => prefix.trim() + ' ')
.trim();
}
function extractInputOutputs(text, actors, useCases) {
const lines = compactWhitespace(text)
.split(/[\.\!\؟\?\n]+/)
.map(cleanSentence)
.filter(Boolean);
const items = [];
for (const line of lines.slice(0, 120)) {
const actor = guessActor(line, actors);
const inputMatch = line.match(/(?:إدخال|إضافة|رفع|استيراد|استقبال|إرسال|تعبئة|تسجيل|إرفاق|كتابة)\s+([^\.:؛،]+)/);
if (inputMatch?.[1]) {
items.push({
type: 'input',
name: cleanupArtifactName(inputMatch[1]),
source: actor,
destination: 'النظام',
description: line,
});
}
const outputMatch = line.match(/(?:عرض|إخراج|توليد|إصدار|إرسال|تصدير|إشعار|تقرير|نتيجة|قائمة|لوحة|ملخص|فاتورة|كشف)\s+([^\.:؛،]+)/);
if (outputMatch?.[1]) {
items.push({
type: 'output',
name: cleanupArtifactName(outputMatch[1]),
source: 'النظام',
destination: actor,
description: line,
});
}
}
const normalized = normalizeInputOutputs(items);
if (normalized.length >= 2) return normalized.slice(0, 20);
return deriveInputOutputsFromUseCases(useCases).slice(0, 20);
}
function analyzeSrsHeuristically(text, title = '') {
const content = compactWhitespace(text).slice(0, 24000);
let actors = extractActors(content);
const useCases = extractUseCases(content, actors);
if (!actors.length && useCases.length) actors = uniqueActorsFromUseCases(useCases);
if (!actors.length) actors = ['المستخدم'];
const inputOutputs = extractInputOutputs(content, actors, useCases);
const functionPoints = buildFunctionPointsFromArtifacts(useCases, inputOutputs);
const summary = summarizeText(content, 300) || `نظام ${title || 'برمجي'} لمعالجة العمليات الواردة في المستند.`;
const counts = {
actors: actors.length,
useCases: useCases.length,
inputs: inputOutputs.filter((item) => item.type === 'input').length,
outputs: inputOutputs.filter((item) => item.type === 'output').length,
};
return normalizeSrsDraft({
summary,
actors,
useCases,
inputOutputs,
functionPoints,
confidence: GEMINI_API_KEY ? 'medium' : 'low',
question: `قرأت ملف SRS واقترحت ${counts.actors} Actors و${counts.useCases} Use Cases و${counts.inputs} Inputs و${counts.outputs} Outputs. راجعها ثم أخبرني: هل تعتمدها أم تريد تعديلها؟`,
});
}
function sanitizePayload(payload, fallback = {}) {
const base = fallback || {};
const draft = {
reply: cleanSentence(payload?.reply || base.reply || ''),
stage: STAGES.has(payload?.stage) ? payload.stage : (base.stage || 'exploration'),
scope: cleanSentence(payload?.scope || base.scope || ''),
actors: Array.isArray(payload?.actors) ? normalizeActors(payload.actors) : normalizeActors(base.actors || []),
useCases: Array.isArray(payload?.useCases) ? normalizeUseCases(payload.useCases) : normalizeUseCases(base.useCases || []),
inputOutputs: Array.isArray(payload?.inputOutputs) ? normalizeInputOutputs(payload.inputOutputs) : normalizeInputOutputs(base.inputOutputs || []),
functionPoints: Array.isArray(payload?.functionPoints) ? normalizeFunctionPoints(payload.functionPoints) : normalizeFunctionPoints(base.functionPoints || []),
isComplete: Boolean(payload?.isComplete ?? base.isComplete ?? false),
};
if (!draft.actors.length && draft.useCases.length) draft.actors = uniqueActorsFromUseCases(draft.useCases);
if (!draft.inputOutputs.length && draft.useCases.length) draft.inputOutputs = deriveInputOutputsFromUseCases(draft.useCases);
if (!draft.functionPoints.length && (draft.useCases.length || draft.inputOutputs.length)) {
draft.functionPoints = buildFunctionPointsFromArtifacts(draft.useCases, draft.inputOutputs);
}
return draft;
}
function normalizeSrsDraft(input = {}) {
const draft = {
summary: cleanSentence(input.summary || input.scope || ''),
actors: normalizeActors(input.actors || []),
useCases: normalizeUseCases(input.useCases || []),
inputOutputs: normalizeInputOutputs(input.inputOutputs || []),
functionPoints: normalizeFunctionPoints(input.functionPoints || []),
confidence: ['high', 'medium', 'low'].includes(input.confidence) ? input.confidence : 'medium',
question: cleanSentence(input.question || ''),
};
if (!draft.actors.length && draft.useCases.length) draft.actors = uniqueActorsFromUseCases(draft.useCases);
if (!draft.inputOutputs.length && draft.useCases.length) draft.inputOutputs = deriveInputOutputsFromUseCases(draft.useCases);
if (!draft.functionPoints.length && (draft.useCases.length || draft.inputOutputs.length)) {
draft.functionPoints = buildFunctionPointsFromArtifacts(draft.useCases, draft.inputOutputs);
}
draft.counts = {
actors: draft.actors.length,
useCases: draft.useCases.length,
inputs: draft.inputOutputs.filter((item) => item.type === 'input').length,
outputs: draft.inputOutputs.filter((item) => item.type === 'output').length,
};
if (!draft.question) {
draft.question = `وجدت ${draft.counts.actors} Actors و${draft.counts.useCases} Use Cases و${draft.counts.inputs} Inputs و${draft.counts.outputs} Outputs. هل تريد اعتمادها أم تعديلها؟`;
}
return draft;
}
function refreshSession(session) {
const next = {
...session,
title: cleanSentence(session.title || 'مشروع غير مسمى'),
scope: cleanSentence(session.scope || ''),
actors: normalizeActors(session.actors || []),
useCases: normalizeUseCases(session.useCases || []),
inputOutputs: normalizeInputOutputs(session.inputOutputs || []),
functionPoints: normalizeFunctionPoints(session.functionPoints || []),
planning: normalizePlanning(session.planning || DEFAULT_PLANNING),
history: Array.isArray(session.history) ? session.history : [],
srsDraft: session.srsDraft ? normalizeSrsDraft(session.srsDraft) : null,
isComplete: Boolean(session.isComplete),
needsStructureConfirmation: Boolean(session.needsStructureConfirmation),
createdAt: cleanSentence(session.createdAt || nowIso()),
updatedAt: cleanSentence(session.updatedAt || session.createdAt || nowIso()),
};
if (!next.actors.length && next.useCases.length) next.actors = uniqueActorsFromUseCases(next.useCases);
if (!next.inputOutputs.length && next.useCases.length) next.inputOutputs = deriveInputOutputsFromUseCases(next.useCases);
if (!next.functionPoints.length && (next.useCases.length || next.inputOutputs.length)) {
next.functionPoints = buildFunctionPointsFromArtifacts(next.useCases, next.inputOutputs);
}
next.stage = inferStage(next);
return next;
}
function loadSessions() {
fs.mkdirSync(path.dirname(STORAGE_FILE), { recursive: true });
if (!fs.existsSync(STORAGE_FILE)) {
fs.writeFileSync(STORAGE_FILE, '[]', 'utf8');
return new Map();
}
try {
const parsed = JSON.parse(fs.readFileSync(STORAGE_FILE, 'utf8') || '[]');
return new Map(
(Array.isArray(parsed) ? parsed : []).map((item) => {
const session = refreshSession(item || {});
return [session.id, session];
})
);
} catch (error) {
console.error('Failed to load sessions store:', error.message);
return new Map();
}
}
function persistSessions() {
const tmpFile = `${STORAGE_FILE}.tmp`;
const payload = JSON.stringify([...sessions.values()], null, 2);
fs.writeFileSync(tmpFile, payload, 'utf8');
fs.renameSync(tmpFile, STORAGE_FILE);
}
function saveSession(session) {
const normalized = refreshSession({ ...session, updatedAt: nowIso() });
sessions.set(normalized.id, normalized);
persistSessions();
return normalized;
}
function buildPayloadSnapshot(session, reply) {
const normalized = refreshSession(session);
return {
reply: cleanSentence(reply || buildNextQuestion(normalized)),
stage: normalized.stage,
scope: normalized.scope,
actors: normalized.actors,
functionPoints: normalized.functionPoints,
useCases: normalized.useCases,
inputOutputs: normalized.inputOutputs,
isComplete: normalized.isComplete,
};
}
function summarizeCheckpoint(session) {
if (session.needsStructureConfirmation) return 'مراجعة اقتراحات SRS';
if (session.isComplete) return 'التقرير النهائي جاهز';
if (session.stage === 'evaluation') return 'مرحلة التقدير والتحليل';
if (session.stage === 'usecase') return 'مرحلة توصيف حالات الاستخدام';
if (session.stage === 'exploration') return 'مرحلة جمع المتطلبات';
return 'بداية الجلسة';
}
function buildLlmWarning(error) {
const raw = cleanSentence(error?.message || 'تعذر الوصول إلى خدمة Gemini.');
if (/429/.test(raw)) return 'ملاحظة: خدمة Gemini غير متاحة حالياً بسبب تجاوز الحصة (429)، لذا سأتابع بالمحلل المحلي الاحتياطي.';
if (/401|403/.test(raw)) return 'ملاحظة: خدمة Gemini غير متاحة حالياً بسبب مشكلة صلاحيات، لذا سأتابع بالمحلل المحلي الاحتياطي.';
return `ملاحظة: تعذر استخدام Gemini حالياً (${raw.slice(0, 90)}), لذا سأتابع بالمحلل المحلي الاحتياطي.`;
}
function buildResumeGreeting(session) {
const checkpoint = summarizeCheckpoint(session);
return `أهلاً بك مجدداً، لقد توقفنا عند ${checkpoint}. لدينا حالياً ${session.actors.length} Actors و${session.useCases.length} Use Cases و${session.functionPoints.length} Function Points. ${buildNextQuestion(session)}`;
}
function computeFPMetrics(session) {
const planning = normalizePlanning(session.planning || DEFAULT_PLANNING);
const points = normalizeFunctionPoints(
session.functionPoints?.length ? session.functionPoints : buildFunctionPointsFromArtifacts(session.useCases, session.inputOutputs)
);
const totalFP = points.reduce((sum, item) => sum + (Number(item.fpScore) || 0), 0);
const ioDensity = session.inputOutputs?.length ? session.inputOutputs.length / Math.max(1, points.length) : 1;
const integrationFactor = (session.actors || []).filter(isExternalActor).length * 0.03;
const adjustmentFactor = +(1 + Math.max(0, ioDensity - 1) * 0.05 + integrationFactor).toFixed(2);
const hoursPerPoint = 18;
const effortHours = +(totalFP * hoursPerPoint * adjustmentFactor).toFixed(1);
const effortDays = +(effortHours / planning.hoursPerDay).toFixed(1);
const personMonths = +(effortHours / 160).toFixed(2);
return {
totalFP,
simple: points.filter((item) => item.complexity === 'بسيطة').length,
medium: points.filter((item) => item.complexity === 'متوسطة').length,
complex: points.filter((item) => item.complexity === 'معقدة').length,
hoursPerPoint,
adjustmentFactor,
effortHours,
effortDays,
personMonths,
};
}
function computeUCP(session) {
const planning = normalizePlanning(session.planning || DEFAULT_PLANNING);
const actorRows = (session.actors || []).map((name) => {
const n = String(name || '').toLowerCase();
let type, weight, reason;
if (/api|نظام خارجي|external|service|بوابة الدفع|gateway|بنك/.test(n)) {
type = 'بسيط'; weight = 1; reason = 'واجهة برمجية / نظام خارجي';
} else if (/cli|سطر أوامر|tcp|protocol|قاعدة بيانات|database/.test(n)) {
type = 'متوسط'; weight = 2; reason = 'تفاعل عبر بروتوكول أو خدمة وسيطة';
} else {
type = 'معقد'; weight = 3; reason = 'مستخدم بشري عبر واجهة استخدام';
}
return { name, type, weight, reason };
});
const UAW = actorRows.reduce((sum, item) => sum + item.weight, 0);
const ucRows = (session.useCases || []).map((uc) => {
const transactions = (Array.isArray(uc.mainFlow) ? uc.mainFlow.length : 0) + (Array.isArray(uc.alternateFlow) ? uc.alternateFlow.length : 0);
let type, weight;
if (transactions <= 3) { type = 'بسيط'; weight = 5; }
else if (transactions <= 7) { type = 'متوسط'; weight = 10; }
else { type = 'معقد'; weight = 15; }
return { id: uc.id || '', title: uc.title || '', transactions, type, weight };
});
const UUCW = ucRows.reduce((sum, item) => sum + item.weight, 0);
const integrationCount = (session.actors || []).filter(isExternalActor).length;
const reportOutputs = (session.inputOutputs || []).filter((item) => item.type === 'output' && /تقرير|لوحة|كشف|ملخص|dashboard|report/i.test(item.name)).length;
const TFactor = Math.max(20, Math.min(45, 28 + integrationCount * 2 + Math.min(3, reportOutputs)));
const EFactor = Math.max(10, Math.min(25, 16 + (planning.developers <= 2 ? 2 : 0) + ((session.useCases || []).length > 10 ? 1 : 0)));
const TCF = +(0.6 + 0.01 * TFactor).toFixed(2);
const ECF = +(1.4 - 0.03 * EFactor).toFixed(2);
const UUCP = UAW + UUCW;
const UCP = +(UUCP * TCF * ECF).toFixed(2);
const effortHours = +(UCP * 20).toFixed(1);
const effortDays = +(effortHours / planning.hoursPerDay).toFixed(1);
const personMonths = +(effortHours / 160).toFixed(2);
return { actorRows, ucRows, UAW, UUCW, UUCP, TFactor, EFactor, TCF, ECF, UCP, effortHours, effortDays, personMonths };
}
function computeDiscrepancyAnalysis(session, deps = {}) {
const fp = deps.fp || computeFPMetrics(session);
const ucp = deps.ucp || computeUCP(session);
const fpHours = fp.effortHours || 0;
const ucpHours = ucp.effortHours || 0;
const average = (fpHours + ucpHours) / 2 || 1;
const deltaPercent = +((Math.abs(fpHours - ucpHours) / average) * 100).toFixed(1);
const dominantMethod = fpHours >= ucpHours ? 'FP' : 'UCP';
const externalActors = (session.actors || []).filter(isExternalActor).length;
const humanActors = Math.max(0, (session.actors || []).length - externalActors);
const dataLoad = (session.inputOutputs || []).length + (session.functionPoints || []).filter((item) => /بيان|بيانات|ملف|أرشفة|استيراد|تصدير|تكامل|مزامنة|تقارير/i.test(`${item.name} ${item.description}`)).length;
const workflowLoad = (session.useCases || []).reduce((sum, uc) => sum + (uc.mainFlow?.length || 0) + (uc.alternateFlow?.length || 0), 0) + humanActors * 2;
const evidence = [];
let narrative = 'ما يزال التحليل مبكراً؛ نحتاج مزيداً من التفاصيل لإظهار فارق دقيق بين الطريقتين.';
let recommendation = 'أكمل توصيف حالات الاستخدام والمدخلات/المخرجات للحصول على مقارنة أدق.';
if (fpHours > 0 && ucpHours > 0) {
if (deltaPercent < 10) {
narrative = `الفارق بين تقدير FP وUCP محدود (${deltaPercent}%)، ما يعني أن صورة المشروع متوازنة بين حجم البيانات ومسارات التفاعل.`;
recommendation = 'يمكن اعتماد المتوسط المرجّح الحالي كأساس أولي للتخطيط والتنفيذ.';
} else if (dominantMethod === 'FP') {
if (dataLoad > workflowLoad) evidence.push('المشروع يبدو غنياً بعمليات البيانات والمدخلات/المخرجات مقارنة بعدد الرحلات التفاعلية.');
if (externalActors > 0) evidence.push('وجود تكاملات أو أنظمة خارجية يرفع وزن الوظائف الخلفية في FP.');
if (humanActors <= Math.max(2, Math.ceil((session.useCases || []).length / 4))) evidence.push('عدد الممثلين البشريين محدود نسبياً، لذلك تبقى UCP أقل من FP.');
if (!evidence.length) evidence.push('تعريف الوظائف الحالي يركز على معالجة البيانات أكثر من تفاعل الواجهة.');
narrative = `هناك تفاوت بنسبة ${deltaPercent}% بين تقدير FP وUCP، ويميل FP إلى الأعلى لأن ${evidence.join(' ')}`;
recommendation = deltaPercent >= 20
? 'يُفضّل مراجعة حالات الاستخدام التفاعلية والواجهات البشرية للتأكد من أنها ممثلة بالكامل قبل تثبيت الجدول الزمني.'
: 'الفارق مقبول، لكن يستحسن تدقيق عدد الواجهات والتقارير قبل اعتماد الخطة النهائية.';
} else {
if (workflowLoad >= dataLoad) evidence.push('عدد الرحلات التفاعلية والخطوات داخل حالات الاستخدام مرتفع.');
if (humanActors >= 3) evidence.push('تعدد الممثلين وواجهات التفاعل يدفع UCP إلى الأعلى.');
if ((session.inputOutputs || []).length <= Math.max(2, Math.ceil((session.useCases || []).length / 2))) evidence.push('ملفات البيانات والمخرجات الخلفية المحددة ما زالت أقل نسبياً من حجم التفاعل.');
if (!evidence.length) evidence.push('الوصف الحالي يعطي وزناً أكبر لتتابعات الاستخدام مقارنة بطبقة البيانات.');
narrative = `هناك تفاوت بنسبة ${deltaPercent}% بين تقدير FP وUCP، ويميل UCP إلى الأعلى لأن ${evidence.join(' ')}`;
recommendation = deltaPercent >= 20
? 'راجِع ملفات البيانات، التقارير، والتكاملات الخلفية حتى لا يكون تقدير FP أقل من الواقع.'
: 'احفظ هذا الفارق كمخزن احتياطي بسيط أثناء التخطيط، خاصةً في مراحل التنفيذ الأولى.';
}
}
const risk = deltaPercent >= 25 ? 'مرتفع' : deltaPercent >= 12 ? 'متوسط' : 'منخفض';
const recommendedHours = fpHours && ucpHours
? +(deltaPercent >= 15 ? Math.max(fpHours, ucpHours) : (fpHours + ucpHours) / 2).toFixed(1)
: +(Math.max(fpHours, ucpHours) || 0).toFixed(1);
return { fpHours, ucpHours, deltaPercent, dominantMethod, narrative, recommendation, evidence, risk, recommendedHours };
}
function chunkArray(items, partsCount) {
const count = Math.max(1, partsCount);
const chunks = Array.from({ length: count }, () => []);
items.forEach((item, index) => chunks[index % count].push(item));
return chunks;
}
function buildStaffingPlan(developers, baselineHours) {
if (developers <= 1) {
return [{ name: 'المطور الوحيد', focus: 'التحليل + التطوير + الاختبار', loadHours: baselineHours, loadPercent: 100 }];
}
const tracks = ['النواة والبيانات', 'واجهات المستخدم', 'التكاملات والجودة', 'التقارير والإطلاق'];
return Array.from({ length: developers }, (_, index) => ({
name: `المطور ${index + 1}`,
focus: tracks[index % tracks.length],
loadHours: +(baselineHours / developers).toFixed(1),
loadPercent: +(100 / developers).toFixed(1),
}));
}
function buildSprintPlan(session, planning, totalSprints, milestones) {
const ucTitles = (session.useCases || []).map((uc) => uc.title);
if (totalSprints <= 1) {
return [{
name: 'Sprint 1',
durationWeeks: planning.sprintWeeks,
goal: 'إطلاق نسخة أولية مصغرة',
focus: uniqueStrings([
...milestones[0].deliverables,
...milestones[1].deliverables,
...ucTitles.slice(0, 4).map((title) => `بناء ${title}`),
]),
}];
}
if (totalSprints === 2) {
return [
{
name: 'Sprint 1',
durationWeeks: planning.sprintWeeks,
goal: 'تثبيت النطاق والتصميم الأساسي',
focus: uniqueStrings([...milestones[0].deliverables, ...milestones[1].deliverables.slice(0, 2)]),
},
{
name: 'Sprint 2',
durationWeeks: planning.sprintWeeks,
goal: 'التنفيذ والاختبار والإطلاق',
focus: uniqueStrings([
...ucTitles.map((title) => `بناء ${title}`),
...milestones[3].deliverables,
...milestones[4].deliverables,
]),
},
];
}
const buildSprintCount = Math.max(1, totalSprints - 2);
const ucChunks = chunkArray(ucTitles, buildSprintCount);
const sprints = [
{
name: 'Sprint 1',
durationWeeks: planning.sprintWeeks,
goal: 'اعتماد المتطلبات وبناء backlog قابل للتنفيذ',
focus: uniqueStrings([...milestones[0].deliverables, ...milestones[1].deliverables]),
},
];
ucChunks.forEach((chunk, index) => {
sprints.push({
name: `Sprint ${index + 2}`,
durationWeeks: planning.sprintWeeks,
goal: `تنفيذ الدفعة ${index + 1} من الرحلات الأساسية`,
focus: chunk.length
? chunk.map((title) => `بناء ${title}`)
: ['بناء النواة الفنية', 'تجهيز الاختبارات', 'تحسين الأداء'],
});
});
sprints.push({
name: `Sprint ${totalSprints}`,
durationWeeks: planning.sprintWeeks,
goal: 'التثبيت، الاختبار، والتسليم',
focus: uniqueStrings([...milestones[3].deliverables, ...milestones[4].deliverables]),
});
return sprints.slice(0, totalSprints);
}
function computePlan(session, deps = {}) {
const planning = normalizePlanning(session.planning || DEFAULT_PLANNING);
const fp = deps.fp || computeFPMetrics(session);
const ucp = deps.ucp || computeUCP(session);
const discrepancy = deps.discrepancy || computeDiscrepancyAnalysis(session, { fp, ucp });
const baselineHours = discrepancy.recommendedHours || Math.max(fp.effortHours, ucp.effortHours, 0);
const personMonths = +(baselineHours / 160).toFixed(2);
const calendarWeeks = baselineHours ? +(baselineHours / (planning.developers * planning.hoursPerDay * 5)).toFixed(1) : 0;
const totalSprints = Math.max(1, Math.ceil((calendarWeeks || planning.sprintWeeks) / planning.sprintWeeks));
const milestones = [
{
name: 'الاكتشاف وتثبيت المتطلبات',
percent: 12,
owner: 'محلل نظم + قائد الفريق',
deliverables: ['اعتماد Actors / Use Cases / IO', 'تنقية SRS وتحديث النطاق', 'سجل مخاطر أولي'],
},
{
name: 'التحليل التفصيلي والتصميم',
percent: 18,
owner: 'قائد تقني + مطور أساسي',
deliverables: ['نموذج البيانات', 'تصميم الشاشات', 'عقود التكامل والتقارير'],
},
{
name: 'التنفيذ المرحلي',
percent: 44,
owner: `${planning.developers} مطور/مطورين`,
deliverables: ['تطوير الوظائف الأساسية', 'بناء حالات الاستخدام ذات الأولوية', 'تنفيذ التقارير والتكاملات'],
},
{
name: 'الاختبار وضبط الجودة',
percent: 18,
owner: 'QA + المطورون',
deliverables: ['اختبارات تكامل', 'اختبارات قبول المستخدم', 'إصلاح العيوب الحرجة'],
},
{
name: 'الإطلاق والتسليم',
percent: 8,
owner: 'قائد الفريق + العميل',
deliverables: ['تهيئة البيئة', 'تدريب المستخدمين', 'التسليم النهائي'],
},
];
if (discrepancy.risk === 'مرتفع') {
milestones[0].percent += 4;
milestones[2].percent -= 2;
milestones[4].percent -= 2;
}
if ((session.actors || []).filter(isExternalActor).length > 0) {
milestones[1].percent += 3;
milestones[2].percent -= 3;
}
const withHours = milestones.map((item) => {
const hours = +(baselineHours * item.percent / 100).toFixed(1);
const weeks = baselineHours ? +(hours / (planning.developers * planning.hoursPerDay * 5)).toFixed(1) : 0;
return { ...item, hours, weeks };
});
const sprintPlan = buildSprintPlan(session, planning, totalSprints, withHours);
const staffing = buildStaffingPlan(planning.developers, baselineHours);
const baselineLabel = discrepancy.deltaPercent >= 15
? `أساس محافظ (${discrepancy.dominantMethod} الأعلى)`
: 'متوسط مرجّح بين FP وUCP';
return {
planning,
baselineHours,
baselineLabel,
personMonths,
calendarWeeks,
totalSprints,
milestones: withHours,
sprintPlan,
staffing,
recommendation: discrepancy.deltaPercent >= 20
? 'ننصح ببدء Sprint تمهيدي قصير لمراجعة الفجوة بين FP وUCP قبل الالتزام الزمني النهائي.'
: 'يمكن اعتماد هذه الخطة كـ baseline أولي ثم تحسينها بعد أول Sprint.'
};
}
function buildHydratedSession(session, extra = {}) {
const normalized = refreshSession(session);
const fp = computeFPMetrics(normalized);
const ucp = computeUCP(normalized);
const discrepancy = computeDiscrepancyAnalysis(normalized, { fp, ucp });
const plan = computePlan(normalized, { fp, ucp, discrepancy });
return {
sessionId: normalized.id,
id: normalized.id,
title: normalized.title,
stage: normalized.stage,
scope: normalized.scope,
actors: normalized.actors,
functionPoints: normalized.functionPoints,
useCases: normalized.useCases,
inputOutputs: normalized.inputOutputs,
isComplete: normalized.isComplete,
history: normalized.history,
planning: normalized.planning,
srsDraft: normalized.srsDraft,
needsStructureConfirmation: normalized.needsStructureConfirmation,
createdAt: normalized.createdAt,
updatedAt: normalized.updatedAt,
checkpointLabel: summarizeCheckpoint(normalized),
resumeMessage: buildResumeGreeting(normalized),
analytics: { fp, ucp, discrepancy, plan },
...extra,
};
}
function listSessionSummaries() {
return [...sessions.values()]
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
.map((session) => ({
id: session.id,
title: session.title,
stage: session.stage,
checkpointLabel: summarizeCheckpoint(session),
updatedAt: session.updatedAt,
createdAt: session.createdAt,
needsStructureConfirmation: session.needsStructureConfirmation,
counts: {
actors: session.actors.length,
useCases: session.useCases.length,
functionPoints: session.functionPoints.length,
inputOutputs: session.inputOutputs.length,
},
}));
}
function buildOfflineChatPayload(session, message) {
let scope = session.scope || summarizeText(message, 220);
const foundActors = extractActors(message);
let actors = uniqueStrings([...(session.actors || []), ...foundActors]);
let useCases = mergeUseCases(session.useCases, extractUseCases(message, actors));
if (!actors.length && useCases.length) actors = uniqueActorsFromUseCases(useCases);
const inputOutputs = mergeInputOutputs(session.inputOutputs, extractInputOutputs(message, actors, useCases));
const functionPoints = mergeFunctionPoints(session.functionPoints, buildFunctionPointsFromArtifacts(useCases, inputOutputs));
const draft = refreshSession({
...session,
scope,
actors,
useCases,
inputOutputs,
functionPoints,
isComplete: false,
});
if (!draft.scope) draft.scope = summarizeText(message, 220);
draft.stage = inferStage(draft);
const reply = buildNextQuestion(draft);
return buildPayloadSnapshot(draft, reply);
}
function buildGeminiHistory(history) {
return (Array.isArray(history) ? history : [])
.slice(-18)
.map((message) => (
message.role === 'assistant'
? { role: 'model', parts: [{ text: JSON.stringify(message.payload || {}) }] }
: { role: 'user', parts: [{ text: message.text || '' }] }
));
}
async function callGeminiJson({ systemPrompt, history = [], userMessage, seedResponse, fallback }) {
if (!GEMINI_URL) return fallback;
const contents = [
{ role: 'user', parts: [{ text: systemPrompt }] },
{ role: 'model', parts: [{ text: JSON.stringify(seedResponse || fallback || {}) }] },
...history,
{ role: 'user', parts: [{ text: userMessage }] },
];
const response = await fetch(GEMINI_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents,
generationConfig: {
temperature: 0.35,
responseMimeType: 'application/json',
},
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini ${response.status}: ${errorText.slice(0, 180)}`);
}
const data = await response.json();
const rawText = data?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
try {
return JSON.parse(rawText);
} catch {
return fallback;
}
}
async function analyzeSrsWithLLM(session, content) {
const fallback = analyzeSrsHeuristically(content, session.title);
if (!GEMINI_URL) return { ...fallback, warning: 'ملاحظة: GEMINI_API_KEY غير مضبوط، لذا سيتم الاعتماد على المحلل المحلي الاحتياطي.' };
try {
const parsed = await callGeminiJson({
systemPrompt: SRS_ANALYSIS_PROMPT,
userMessage: `عنوان المشروع: ${session.title}\n\nنص SRS:\n${content.slice(0, 18000)}`,
seedResponse: fallback,
fallback,
});
return normalizeSrsDraft(parsed);
} catch (error) {
console.warn('SRS analysis fallback:', error.message);
return { ...fallback, warning: buildLlmWarning(error) };
}
}
async function buildConversationPayload(session, message) {
if (session.needsStructureConfirmation && /(اعتمد|اعتماد|موافق|وافق|confirm|confirmed|تم|نعم)/i.test(message)) {
const next = refreshSession({ ...session, needsStructureConfirmation: false });
return buildPayloadSnapshot(next, `تم اعتماد الهيكل الأولي بنجاح. ${buildNextQuestion(next)}`);
}
const fallback = buildOfflineChatPayload(session, message);
if (!GEMINI_URL) {
return {
...fallback,
reply: `${buildLlmWarning({ message: 'GEMINI_API_KEY غير مضبوط' })} ${fallback.reply}`
};
}
try {
const parsed = await callGeminiJson({
systemPrompt: CHAT_SYSTEM_PROMPT,
history: buildGeminiHistory(session.history),
userMessage: `عنوان المشروع: ${session.title}\n\nرسالة المستخدم:\n${message}`,
seedResponse: fallback,
fallback,
});
return sanitizePayload(parsed, fallback);
} catch (error) {
console.warn('Conversation fallback:', error.message);
return {
...fallback,
reply: `${buildLlmWarning(error)} ${fallback.reply}`
};
}
}
function applyPayloadToSession(session, payload) {
const sanitized = sanitizePayload(payload, session);
let next = {
...session,
scope: sanitized.scope || session.scope,
actors: uniqueStrings([...(session.actors || []), ...(sanitized.actors || [])]),
useCases: mergeUseCases(session.useCases, sanitized.useCases),
inputOutputs: mergeInputOutputs(session.inputOutputs, sanitized.inputOutputs),
functionPoints: mergeFunctionPoints(session.functionPoints, sanitized.functionPoints),
isComplete: Boolean(sanitized.isComplete),
needsStructureConfirmation: session.needsStructureConfirmation && !/(تم اعتماد|اعتماد الهيكل)/.test(sanitized.reply || ''),
history: [...(session.history || [])],
};
next = refreshSession(next);
const finalPayload = buildPayloadSnapshot({ ...next, isComplete: next.isComplete }, sanitized.reply || buildNextQuestion(next));
next.history.push({ role: 'assistant', payload: finalPayload });
next = refreshSession(next);
return { session: next, payload: finalPayload };
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[char]));
}
function buildReportHtml(session) {
const s = refreshSession(session);
const fp = computeFPMetrics(s);
const ucp = computeUCP(s);
const discrepancy = computeDiscrepancyAnalysis(s, { fp, ucp });
const plan = computePlan(s, { fp, ucp, discrepancy });
return `<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>تقرير متطلبات: ${escapeHtml(s.title)}</title>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
body{font-family:'Cairo',sans-serif;margin:0;padding:32px;color:#1b1d27;background:#f7f7fb;line-height:1.8}
.page{max-width:1100px;margin:0 auto;background:#fff;border-radius:18px;padding:32px;box-shadow:0 18px 60px rgba(26,32,44,.08)}
h1{margin:0 0 8px;color:#111827;font-size:30px}
h2{margin-top:32px;padding-right:12px;border-right:4px solid #c9a84c;color:#1f2937;font-size:22px}
h3{margin-top:22px;color:#374151;font-size:18px}
p,li{color:#4b5563}
.meta{display:flex;flex-wrap:wrap;gap:12px;color:#6b7280;font-size:14px;margin-bottom:20px}
.hero{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:24px 0}
.metric{background:linear-gradient(135deg,#111827,#1f2937);color:#fff;border-radius:14px;padding:16px}
.metric .label{font-size:12px;opacity:.8}
.metric .value{font-size:28px;font-weight:800;color:#f5d07b;margin-top:8px}
.panel{background:#f8fafc;border:1px solid #e5e7eb;border-radius:14px;padding:18px;margin:16px 0}
.summary{background:linear-gradient(135deg,#111827,#0f172a);color:#fff}
.summary strong{color:#f5d07b}
table{width:100%;border-collapse:collapse;margin:14px 0;background:#fff}
th,td{border:1px solid #e5e7eb;padding:10px;text-align:right;vertical-align:top;font-size:14px}
th{background:#f8f5ea;color:#111827}
.tag{display:inline-block;padding:4px 10px;border-radius:999px;font-size:12px;background:#ede9fe;color:#5b21b6;margin-left:6px}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.uc-card{border:1px solid #e5e7eb;border-radius:12px;padding:16px;background:#fff;margin-bottom:12px}
.uc-title{font-weight:700;color:#111827;margin-bottom:6px}
.list-reset{margin:8px 0;padding-right:22px}
.btn-print{position:sticky;top:16px;display:inline-block;margin-bottom:16px;background:#c9a84c;color:#111827;border:none;border-radius:999px;padding:12px 22px;font:inherit;font-weight:700;cursor:pointer;box-shadow:0 12px 24px rgba(201,168,76,.28)}
@media print{body{background:#fff;padding:0}.page{box-shadow:none;border-radius:0}.btn-print{display:none}}
@media (max-width:900px){.hero,.grid-2{grid-template-columns:1fr 1fr}}
@media (max-width:640px){body{padding:12px}.page{padding:18px}.hero,.grid-2{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="page">
<button class="btn-print" onclick="window.print()">⬇ حفظ كـ PDF</button>
<h1>وثيقة متطلبات النظام والتقدير الأولي</h1>
<div class="meta">
<span><strong>المشروع:</strong> ${escapeHtml(s.title)}</span>
<span><strong>التاريخ:</strong> ${new Date(s.createdAt).toLocaleDateString('ar-EG')}</span>
<span><strong>آخر تحديث:</strong> ${new Date(s.updatedAt).toLocaleDateString('ar-EG')}</span>
<span><strong>مرحلة الجلسة:</strong> ${escapeHtml(stageLabel(s.stage))}</span>
</div>
<div class="hero">
<div class="metric"><div class="label">Actors</div><div class="value">${s.actors.length}</div></div>
<div class="metric"><div class="label">Use Cases</div><div class="value">${s.useCases.length}</div></div>
<div class="metric"><div class="label">Total FP</div><div class="value">${fp.totalFP}</div></div>
<div class="metric"><div class="label">UCP</div><div class="value">${ucp.UCP}</div></div>
</div>
<h2>1. نطاق النظام</h2>
<div class="panel">${escapeHtml(s.scope || 'لم يُحدَّد نطاق واضح بعد.')}</div>
<h2>2. الأطراف المتفاعلة (Actors)</h2>
<div class="panel">
${(s.actors || []).length
? `<ul class="list-reset">${s.actors.map((actor) => `<li>${escapeHtml(actor)} ${isExternalActor(actor) ? '<span class="tag">External</span>' : ''}</li>`).join('')}</ul>`
: '<p>لا توجد Actors معتمدة بعد.</p>'}
</div>
<h2>3. Catalog المدخلات والمخرجات</h2>
<table>
<thead><tr><th>الرمز</th><th>النوع</th><th>الاسم</th><th>المصدر</th><th>الوجهة</th><th>الوصف</th></tr></thead>
<tbody>
${(s.inputOutputs || []).map((item) => `<tr>
<td>${escapeHtml(item.id)}</td>
<td>${item.type === 'input' ? 'Input' : 'Output'}</td>
<td>${escapeHtml(item.name)}</td>
<td>${escapeHtml(item.source)}</td>
<td>${escapeHtml(item.destination)}</td>
<td>${escapeHtml(item.description || '—')}</td>
</tr>`).join('') || '<tr><td colspan="6">لا توجد عناصر مدخلات/مخرجات بعد.</td></tr>'}
</tbody>
</table>
<h2>4. المتطلبات الوظيفية (Function Points)</h2>
<div class="panel summary">
<div>إجمالي نقاط الوظائف: <strong>${fp.totalFP}</strong></div>
<div>الجهد التقديري بمنهج FP: <strong>${fp.effortHours} ساعة</strong> (~${fp.effortDays} يوم عمل، ${fp.personMonths} شهر/شخص)</div>
<div>عامل التعديل المستخدم: <strong>${fp.adjustmentFactor}</strong> · ساعات لكل FP: <strong>${fp.hoursPerPoint}</strong></div>
</div>
<table>
<thead><tr><th>الرمز</th><th>الوظيفة</th><th>الوصف</th><th>التعقيد</th><th>FP</th></tr></thead>
<tbody>
${(s.functionPoints || []).map((item) => `<tr>
<td>${escapeHtml(item.id)}</td>
<td>${escapeHtml(item.name)}</td>
<td>${escapeHtml(item.description)}</td>
<td>${escapeHtml(item.complexity)}</td>
<td>${item.fpScore}</td>
</tr>`).join('') || '<tr><td colspan="5">لا توجد نقاط وظائف محددة بعد.</td></tr>'}
</tbody>
</table>
<h2>5. حالات الاستخدام</h2>
${(s.useCases || []).length
? s.useCases.map((uc) => `<div class="uc-card">
<div class="uc-title">${escapeHtml(uc.id)}${escapeHtml(uc.title)}</div>
<div><strong>الفاعل:</strong> ${escapeHtml(uc.actor)}</div>
<div><strong>الشروط المسبقة:</strong> ${escapeHtml(uc.preconditions || '—')}</div>
<div><strong>السيناريو الرئيسي:</strong><ol class="list-reset">${(uc.mainFlow || []).map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ol></div>
<div><strong>السيناريو البديل:</strong>${(uc.alternateFlow || []).length ? `<ul class="list-reset">${uc.alternateFlow.map((step) => `<li>${escapeHtml(step)}</li>`).join('')}</ul>` : ' — '}</div>
</div>`).join('')
: '<div class="panel">لا توجد حالات استخدام كافية بعد.</div>'}
<h2>6. حساب Use Case Points (UCP)</h2>
<div class="panel summary">
<div>UAW = <strong>${ucp.UAW}</strong> · UUCW = <strong>${ucp.UUCW}</strong> · UUCP = <strong>${ucp.UUCP}</strong></div>
<div>TCF = <strong>${ucp.TCF}</strong> (TFactor = ${ucp.TFactor}) · ECF = <strong>${ucp.ECF}</strong> (EFactor = ${ucp.EFactor})</div>
<div>UCP = <strong>${ucp.UCP}</strong> · الجهد التقديري = <strong>${ucp.effortHours} ساعة</strong> (~${ucp.effortDays} يوم عمل، ${ucp.personMonths} شهر/شخص)</div>
</div>
<div class="grid-2">
<div>
<h3>تصنيف Actors</h3>
<table>
<thead><tr><th>الفاعل</th><th>التصنيف</th><th>المبرر</th><th>الوزن</th></tr></thead>
<tbody>
${ucp.actorRows.map((item) => `<tr><td>${escapeHtml(item.name)}</td><td>${escapeHtml(item.type)}</td><td>${escapeHtml(item.reason)}</td><td>${item.weight}</td></tr>`).join('') || '<tr><td colspan="4">—</td></tr>'}
</tbody>
</table>
</div>
<div>
<h3>تصنيف Use Cases</h3>
<table>
<thead><tr><th>الحالة</th><th>العمليات</th><th>التصنيف</th><th>الوزن</th></tr></thead>
<tbody>
${ucp.ucRows.map((item) => `<tr><td>${escapeHtml(item.id)}${escapeHtml(item.title)}</td><td>${item.transactions}</td><td>${escapeHtml(item.type)}</td><td>${item.weight}</td></tr>`).join('') || '<tr><td colspan="4">—</td></tr>'}
</tbody>
</table>
</div>
</div>
<h2>7. تحليل الفجوة بين FP وUCP</h2>
<div class="panel summary">
<div>فرق التقدير: <strong>${discrepancy.deltaPercent}%</strong></div>
<div>الطريقة الأعلى: <strong>${escapeHtml(discrepancy.dominantMethod)}</strong></div>
<div>الجهد المرجح المقترح للتخطيط: <strong>${discrepancy.recommendedHours} ساعة</strong></div>
</div>
<div class="panel">
<p>${escapeHtml(discrepancy.narrative)}</p>
${(discrepancy.evidence || []).length ? `<ul class="list-reset">${discrepancy.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : ''}
<p><strong>التوصية:</strong> ${escapeHtml(discrepancy.recommendation)}</p>
</div>
<h2>8. الخطة الأولية للتنفيذ (WBS / Sprint Plan)</h2>
<div class="panel summary">
<div>الأساس المستخدم: <strong>${escapeHtml(plan.baselineLabel)}</strong></div>
<div>الجهد المخطط: <strong>${plan.baselineHours} ساعة</strong> (~${plan.personMonths} شهر/شخص)</div>
<div>عدد المطورين المفترض: <strong>${plan.planning.developers}</strong> · مدة السبرنت: <strong>${plan.planning.sprintWeeks} أسبوع/أسابيع</strong> · المدة المتوقعة: <strong>${plan.calendarWeeks} أسبوع</strong></div>
</div>
<h3>Milestones</h3>
<table>
<thead><tr><th>المرحلة</th><th>النسبة</th><th>الساعات</th><th>الأسابيع</th><th>المالك</th><th>المخرجات</th></tr></thead>
<tbody>
${plan.milestones.map((item) => `<tr>
<td>${escapeHtml(item.name)}</td>
<td>${item.percent}%</td>
<td>${item.hours}</td>
<td>${item.weeks}</td>
<td>${escapeHtml(item.owner)}</td>
<td>${escapeHtml(item.deliverables.join('، '))}</td>
</tr>`).join('')}
</tbody>
</table>
<h3>Sprint Breakdown</h3>
${plan.sprintPlan.map((sprint) => `<div class="uc-card">
<div class="uc-title">${escapeHtml(sprint.name)}${escapeHtml(sprint.goal)}</div>
<div><strong>المدة:</strong> ${sprint.durationWeeks} أسبوع/أسابيع</div>
<ul class="list-reset">${(sprint.focus || []).map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>
</div>`).join('')}
<h3>توزيع العمل على الفريق</h3>
<table>
<thead><tr><th>المورد</th><th>المسار المقترح</th><th>الحمل (ساعات)</th><th>النسبة</th></tr></thead>
<tbody>
${plan.staffing.map((item) => `<tr><td>${escapeHtml(item.name)}</td><td>${escapeHtml(item.focus)}</td><td>${item.loadHours}</td><td>${item.loadPercent}%</td></tr>`).join('')}
</tbody>
</table>
<div class="panel"><strong>ملاحظة:</strong> ${escapeHtml(plan.recommendation)}</div>
</div>
</body>
</html>`;
}
app.get('/api/sessions', (req, res) => {
res.json({ sessions: listSessionSummaries() });
});
app.get('/api/session/:id', (req, res) => {
const session = sessions.get(req.params.id);
if (!session) return res.status(404).json({ error: 'الجلسة غير موجودة' });
res.json(buildHydratedSession(session));
});
app.post('/api/start', (req, res) => {
try {
const title = cleanSentence(req.body?.title || '');
if (!title) return res.status(400).json({ error: 'يرجى إدخال عنوان المشروع' });
const session = saveSession({
id: newId(),
title,
scope: '',
actors: [],
useCases: [],
inputOutputs: [],
functionPoints: [],
planning: DEFAULT_PLANNING,
history: [],
srsDraft: null,
needsStructureConfirmation: false,
isComplete: false,
createdAt: nowIso(),
updatedAt: nowIso(),
});
const opener = `بدأنا مشروعاً جديداً بعنوان "${title}". هل تود أن تبدأ برفع ملف SRS، أم تصف لي الهدف العام للمشروع مباشرة؟`;
session.history.push({ role: 'assistant', payload: buildPayloadSnapshot(session, opener) });
const saved = saveSession(session);
res.json(buildHydratedSession(saved, { reply: opener }));
} catch (error) {
console.error('start error:', error.message);
res.status(500).json({ error: 'فشل بدء الجلسة' });
}
});
app.post('/api/analyze-srs', async (req, res) => {
try {
const session = sessions.get(req.body?.sessionId || '');
if (!session) return res.status(404).json({ error: 'الجلسة غير موجودة' });
const content = compactWhitespace(req.body?.content || '');
const filename = cleanSentence(req.body?.filename || '');
if (!content) return res.status(400).json({ error: 'يرجى إرفاق نص أو ملف SRS بسيط للتحليل' });
const draft = await analyzeSrsWithLLM(session, content);
const analysisSummary = filename
? `قرأت الملف "${filename}" واقترحت ${draft.counts.actors} Actors و${draft.counts.useCases} Use Cases و${draft.counts.inputs} Inputs و${draft.counts.outputs} Outputs. ${draft.question}`
: `تم تحليل النص المرفوع. اقترحت ${draft.counts.actors} Actors و${draft.counts.useCases} Use Cases و${draft.counts.inputs} Inputs و${draft.counts.outputs} Outputs. ${draft.question}`;
const reply = draft.warning ? `${draft.warning} ${analysisSummary}` : analysisSummary;
let next = {
...session,
scope: draft.summary || session.scope,
actors: draft.actors,
useCases: draft.useCases,
inputOutputs: draft.inputOutputs,
functionPoints: draft.functionPoints,
srsDraft: draft,
needsStructureConfirmation: true,
isComplete: false,
history: [...session.history],
};
next = refreshSession(next);
const payload = buildPayloadSnapshot(next, reply);
next.history.push({ role: 'assistant', payload });
const saved = saveSession(next);
res.json(buildHydratedSession(saved, { reply, uploadedFile: filename || null }));
} catch (error) {
console.error('analyze srs error:', error.message);
res.status(500).json({ error: 'فشل تحليل ملف SRS' });
}
});
app.post('/api/confirm-structure', (req, res) => {
try {
const session = sessions.get(req.body?.sessionId || '');
if (!session) return res.status(404).json({ error: 'الجلسة غير موجودة' });
const scope = cleanSentence(req.body?.scope || session.scope || '');
const actors = normalizeActors(req.body?.actors || session.actors);
const useCases = normalizeUseCases(req.body?.useCases || session.useCases);
const inputOutputs = normalizeInputOutputs(req.body?.inputOutputs || session.inputOutputs);
const functionPoints = buildFunctionPointsFromArtifacts(useCases, inputOutputs);
let next = refreshSession({
...session,
scope,
actors,
useCases,
inputOutputs,
functionPoints,
srsDraft: normalizeSrsDraft({ summary: scope, actors, useCases, inputOutputs, functionPoints, confidence: session.srsDraft?.confidence || 'manual' }),
needsStructureConfirmation: false,
isComplete: false,
history: [...session.history],
});
const reply = `تم اعتماد الهيكل الأولي: ${actors.length} Actors، ${useCases.length} Use Cases، و${inputOutputs.length} Inputs/Outputs. ${buildNextQuestion(next)}`;
const payload = buildPayloadSnapshot(next, reply);
next.history.push({ role: 'assistant', payload });
const saved = saveSession(next);
res.json(buildHydratedSession(saved, { reply }));
} catch (error) {
console.error('confirm structure error:', error.message);
res.status(500).json({ error: 'تعذر اعتماد الهيكل المقترح' });
}
});
app.post('/api/session/:id/planning', (req, res) => {
try {
const session = sessions.get(req.params.id);
if (!session) return res.status(404).json({ error: 'الجلسة غير موجودة' });
const next = saveSession({
...session,
planning: normalizePlanning({
...session.planning,
...req.body,
}),
});
res.json(buildHydratedSession(next));
} catch (error) {
console.error('planning error:', error.message);
res.status(500).json({ error: 'فشل تحديث إعدادات التخطيط' });
}
});
app.post('/api/message', async (req, res) => {
try {
const session = sessions.get(req.body?.sessionId || '');
if (!session) return res.status(404).json({ error: 'الجلسة غير موجودة' });
const message = compactWhitespace(req.body?.message || '');
if (!message) return res.status(400).json({ error: 'الرسالة فارغة' });
const baseSession = refreshSession({
...session,
history: [...session.history, { role: 'user', text: message }],
});
const payload = await buildConversationPayload(baseSession, message);
const applied = applyPayloadToSession(baseSession, payload);
const saved = saveSession(applied.session);
res.json(buildHydratedSession(saved, { reply: applied.payload.reply }));
} catch (error) {
console.error('message error:', error.message);
res.status(500).json({ error: error.message || 'خطأ في المعالجة' });
}
});
app.get('/report/:id', (req, res) => {
const session = sessions.get(req.params.id);
if (!session) return res.status(404).send('الجلسة غير موجودة');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(buildReportHtml(session));
});
app.listen(PORT, () => {
console.log(`✨ Smart System Analyst Agent running at http://localhost:${PORT}`);
if (!GEMINI_API_KEY) {
console.warn('⚠️ GEMINI_API_KEY غير مضبوط — سيتم استخدام التحليل المحلي heuristic كخيار احتياطي.');
}
console.log(`💾 Persistent session store: ${STORAGE_FILE}`);
});