1596 lines
71 KiB
JavaScript
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) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
}[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}`);
|
|
});
|