diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index b071e0e..f533e01 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -347,6 +347,23 @@ struct RiskHeatmapItem { heat_level: String, } +#[derive(Clone, Debug, Serialize)] +struct SecurityCorrelationItem { + department: String, + #[serde(skip_serializing_if = "Option::is_none")] + trust_kpi_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + activity_score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + business_risk_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + critical_candidates: Option, + #[serde(skip_serializing_if = "Option::is_none")] + open_cases: Option, + correlation_score: u8, + correlation_reason: String, +} + #[derive(Clone, Debug, Serialize)] struct RiskIncidentCandidate { id: String, @@ -992,6 +1009,7 @@ struct ReportMarkdownContext<'a> { business_risk_history: &'a [BusinessRiskHistoryItem], business_risk_history_summary: &'a BusinessRiskHistorySummary, risk_heatmap: &'a [RiskHeatmapItem], + security_correlation: &'a [SecurityCorrelationItem], risk_incident_candidates: &'a [RiskIncidentCandidate], incident_review_audit_summary: &'a IncidentReviewAuditSummary, } @@ -2299,6 +2317,7 @@ fn build_reports( &risk_incident_candidates, inputs.cases, ); + let security_correlation = build_security_correlation(&risk_heatmap); let executive_dashboard = build_executive_dashboard( snapshot, &agent_quality_explain, @@ -2413,6 +2432,16 @@ fn build_reports( item.open_cases.unwrap_or(0) )); } + for item in security_correlation + .iter() + .filter(|item| item.correlation_score > 0) + .take(3) + { + executive_points.push(format!( + "Корреляция Workforce/Security {}: {}/100 — {}", + item.department, item.correlation_score, item.correlation_reason + )); + } executive_points.push(format!( "Главный риск: {}", executive_dashboard.summary.main_risk @@ -2447,6 +2476,7 @@ fn build_reports( business_risk_history: &business_risk_history, business_risk_history_summary: &business_risk_history_summary, risk_heatmap: &risk_heatmap, + security_correlation: &security_correlation, risk_incident_candidates: &risk_incident_candidates, incident_review_audit_summary: &incident_review_audit_summary, }, @@ -2541,6 +2571,7 @@ fn build_reports( "business_risk_history": business_risk_history, "business_risk_history_summary": business_risk_history_summary, "risk_heatmap": risk_heatmap, + "security_correlation": security_correlation, "risk_incident_candidates": risk_incident_candidates, "incident_review_audit_summary": incident_review_audit_summary, "workforce_policy": workforce_policy_explain, @@ -3191,6 +3222,92 @@ fn heatmap_rank(level: &str) -> u8 { } } +fn build_security_correlation(heatmap: &[RiskHeatmapItem]) -> Vec { + let mut items = heatmap + .iter() + .map(security_correlation_item) + .collect::>(); + items.sort_by(|left, right| { + right + .correlation_score + .cmp(&left.correlation_score) + .then_with(|| { + heatmap_rank(right.business_risk_level.as_deref().unwrap_or("UNKNOWN")).cmp( + &heatmap_rank(left.business_risk_level.as_deref().unwrap_or("UNKNOWN")), + ) + }) + .then_with(|| left.department.cmp(&right.department)) + }); + items +} + +fn security_correlation_item(item: &RiskHeatmapItem) -> SecurityCorrelationItem { + let mut score = 0u64; + let mut reasons = Vec::new(); + let trust = item.trust_kpi_score; + let activity = item.activity_score; + let business_level = item.business_risk_level.as_deref().unwrap_or("UNKNOWN"); + let critical_candidates = item.critical_candidates.unwrap_or(0); + let open_cases = item.open_cases.unwrap_or(0); + + match business_level { + "CRITICAL" => score += 40, + "HIGH" => score += 30, + "MEDIUM" => score += 15, + _ => {} + } + if matches!(business_level, "HIGH" | "CRITICAL") && trust.is_some_and(|value| value < 75) { + reasons.push("низкий Trust KPI + высокий риск".to_string()); + } + if let Some(value) = trust { + if value < 50 { + score += 25; + } else if value < 75 { + score += 15; + } + } + if let Some(value) = activity { + if value < 35 { + score += 20; + } else if value < 60 { + score += 10; + } + } + if activity.is_some_and(|value| value < 60) && critical_candidates > 0 { + reasons.push("снижение активности + рост кандидатов".to_string()); + } + if let Some(value) = item.agent_coverage_pct { + if value < 75 { + score += 25; + reasons.push("большое количество отсутствующих агентов".to_string()); + } else if value < 90 { + score += 10; + } + } else if business_level != "UNKNOWN" { + reasons.push("покрытие агентов не подтверждено".to_string()); + } + if critical_candidates > 0 { + score += (critical_candidates as u64).saturating_mul(20).min(35); + } + if open_cases > 0 { + score += (open_cases as u64).saturating_mul(15).min(30); + reasons.push("рост открытых расследований".to_string()); + } + if reasons.is_empty() { + reasons.push("прямая связка Workforce и Security не выражена".to_string()); + } + SecurityCorrelationItem { + department: item.department.clone(), + trust_kpi_score: item.trust_kpi_score, + activity_score: item.activity_score, + business_risk_level: item.business_risk_level.clone(), + critical_candidates: item.critical_candidates, + open_cases: item.open_cases, + correlation_score: score.min(100) as u8, + correlation_reason: reasons.join("; "), + } +} + fn build_executive_dashboard( snapshot: &Snapshot, agent_quality_explain: &AgentQualityExplain, @@ -5360,6 +5477,7 @@ fn render_report_markdown( context.business_risk_history_summary, ); append_risk_heatmap_markdown(&mut text, context.risk_heatmap); + append_security_correlation_markdown(&mut text, context.security_correlation); append_risk_incident_candidates_markdown(&mut text, context.risk_incident_candidates); append_incident_review_markdown(&mut text, context.risk_incident_candidates); append_incident_review_audit_markdown( @@ -5672,6 +5790,28 @@ fn append_risk_heatmap_markdown(text: &mut String, items: &[RiskHeatmapItem]) { } } +fn append_security_correlation_markdown(text: &mut String, items: &[SecurityCorrelationItem]) { + text.push_str("\n## Корреляция Workforce ↔ Security\n\n"); + if items.is_empty() { + text.push_str("- Корреляция пока не рассчитана: недостаточно данных по подразделениям.\n"); + return; + } + text.push_str("- Примечание: это аналитическая связка признаков, она не создает инциденты автоматически.\n"); + for item in items.iter().take(10) { + text.push_str(&format!( + "- {}: score={}/100, Trust={}, activity={}, business_risk={}, critical_candidates={}, open_cases={}\n", + item.department, + item.correlation_score, + optional_score_text(item.trust_kpi_score), + optional_score_text(item.activity_score), + item.business_risk_level.as_deref().unwrap_or("UNKNOWN"), + item.critical_candidates.unwrap_or(0), + item.open_cases.unwrap_or(0) + )); + text.push_str(&format!(" - причина: {}\n", item.correlation_reason)); + } +} + fn append_risk_incident_candidates_markdown( text: &mut String, candidates: &[RiskIncidentCandidate], @@ -9393,6 +9533,18 @@ mod tests { assert_eq!(report["risk_heatmap"][0]["heat_level"], "HIGH"); assert_eq!(report["risk_heatmap"][0]["trust_kpi_score"], 50); assert_eq!(report["risk_heatmap"][0]["activity_score"], 50); + assert!(report["security_correlation"].is_array()); + assert_eq!( + report["security_correlation"][0]["department"], + "Бухгалтерия" + ); + assert_eq!(report["security_correlation"][0]["correlation_score"], 40); + assert!( + report["security_correlation"][0]["correlation_reason"] + .as_str() + .unwrap() + .contains("покрытие агентов") + ); assert_eq!(report["business_risk_history"].as_array().unwrap().len(), 3); assert_eq!( report["business_risk_history"][0]["department"], @@ -9558,6 +9710,16 @@ mod tests { .iter() .any(|item| item.as_str().unwrap().contains("Проблемная зона")) ); + assert!( + report["executive_points"] + .as_array() + .unwrap() + .iter() + .any(|item| item + .as_str() + .unwrap() + .contains("Корреляция Workforce/Security")) + ); assert!( report["executive_points"] .as_array() @@ -9577,6 +9739,12 @@ mod tests { .unwrap() .contains("## Карта рисков подразделений") ); + assert!( + report["markdown"] + .as_str() + .unwrap() + .contains("## Корреляция Workforce ↔ Security") + ); assert!( report["markdown"] .as_str() diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 669c54d..4f8774a 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -877,6 +877,7 @@ function renderOperator(data, report) { ${renderAgentQualityNodes(report?.agent_quality_nodes, report?.agent_quality_nodes_summary)} ${renderAgentCoverageSla(report?.agent_coverage_sla)} ${renderRiskHeatmap(report?.risk_heatmap)} + ${renderSecurityCorrelation(report?.security_correlation)} ${renderBusinessRisk(report?.business_risk)} ${renderBusinessRiskTimeline(report?.business_risk_history, report?.business_risk_history_summary)} ${renderRiskIncidentCandidates(report?.risk_incident_candidates)} @@ -1827,6 +1828,61 @@ function riskPercentText(value) { return Number.isFinite(number) ? `${Math.round(number)}%` : "UNKNOWN"; } +function renderSecurityCorrelation(items) { + const rows = Array.isArray(items) ? items.slice(0, 10) : []; + const score = Number(rows[0]?.correlation_score || 0); + const status = score >= 80 ? "CRITICAL" : score >= 60 ? "HIGH" : score >= 35 ? "MEDIUM" : rows.length ? "LOW" : "UNKNOWN"; + return ` +
+
+
+

Корреляция Workforce и Security

+

Связь между падением активности, доверием к KPI, кандидатами в инциденты и открытыми делами. Инциденты автоматически не создаются.

+
+ ${ui(status)} +
+
+ + + + + + + + + + + + + + ${rows.length ? rows.map(item => ` + + + + + + + + + + `).join("") : ` + + + + + + + + + + `} + +
ПодразделениеTrust KPIАктивностьБизнес-рискSecurityКорреляцияПричина
${ui(item.department || "Без подразделения")}${ui(riskPercentText(item.trust_kpi_score))}${ui(riskPercentText(item.activity_score))}${ui(item.business_risk_level || "UNKNOWN")}кандидаты ${ui(item.critical_candidates ?? 0)} · дела ${ui(item.open_cases ?? 0)}${ui(Number(item.correlation_score || 0))}/100${ui(item.correlation_reason || "связь не выражена")}
Нет данныхUNKNOWNUNKNOWNUNKNOWNкандидаты 0 · дела 00/100недостаточно данных по подразделениям
+
+
+ `; +} + function businessRiskReasons(item) { const reasons = Array.isArray(item?.reasons) && item.reasons.length ? item.reasons.join("; ") @@ -2077,6 +2133,7 @@ function renderReports(data) { ${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)} ${renderAgentCoverageSla(data.agent_coverage_sla)} ${renderRiskHeatmap(data.risk_heatmap)} + ${renderSecurityCorrelation(data.security_correlation)} ${renderBusinessRisk(data.business_risk)} ${renderBusinessRiskTimeline(data.business_risk_history, data.business_risk_history_summary)} ${renderRiskIncidentCandidates(data.risk_incident_candidates)} diff --git a/docs/BUSINESS_RISK_RU.md b/docs/BUSINESS_RISK_RU.md index 1fcec9a..66045e0 100644 --- a/docs/BUSINESS_RISK_RU.md +++ b/docs/BUSINESS_RISK_RU.md @@ -40,6 +40,8 @@ Business Risk не является автоматическим обвинен рискам, кандидатам, делам и готовности расследований; - `risk_heatmap` - карта рисков подразделений по Trust KPI, активности, покрытию агентов, открытым делам и кандидатам в инциденты; +- `security_correlation` - аналитическая связка Workforce и Security: + активность, Trust KPI, Business Risk, кандидаты и открытые дела; - `business_risk_history` - timeline риска по подразделениям; - `business_risk_history_summary` - сводка динамики. - `risk_incident_candidates` - read-only кандидаты для ручной проверки. @@ -74,6 +76,19 @@ Business Risk не является автоматическим обвинен - `heat_level` - итоговая зона карты: `LOW`, `MEDIUM`, `HIGH`, `CRITICAL` или `UNKNOWN`. +`security_correlation`: + +- `department` - подразделение; +- `trust_kpi_score` - доверие к KPI активности, если доступно; +- `activity_score` - индекс активности подразделения, если доступен; +- `business_risk_level` - уровень Business Risk; +- `critical_candidates` - число кандидатов `HIGH`/`CRITICAL`; +- `open_cases` - открытые дела по подразделению; +- `correlation_score` - сила связки Workforce и Security от 0 до 100; +- `correlation_reason` - человеко-понятное объяснение связи, например + `низкий Trust KPI + высокий риск` или + `снижение активности + рост кандидатов`. + Элемент `business_risk_history`: - `date` - дата daily point; @@ -317,6 +332,7 @@ POST /api/cases/{case_id}/status ```text ## Сводка руководителя ## Карта рисков подразделений +## Корреляция Workforce ↔ Security ## Риски подразделений ## Динамика бизнес-рисков ## Кандидаты в инциденты diff --git a/scripts/detmir-portal-tabs-smoke.mjs b/scripts/detmir-portal-tabs-smoke.mjs index 2c7cd60..c41f1b3 100644 --- a/scripts/detmir-portal-tabs-smoke.mjs +++ b/scripts/detmir-portal-tabs-smoke.mjs @@ -131,6 +131,7 @@ async function main() { "Качество данных по рабочим местам", "Покрытие агентов", "Карта рисков подразделений", + "Корреляция Workforce и Security", "Риски подразделений", "Динамика рисков", "Кандидаты в инциденты",