feat(workforce): add role-based activity weights

This commit is contained in:
igor04091968
2026-06-03 11:21:33 +03:00
parent 5ec9f508db
commit 233524049e
9 changed files with 396 additions and 18 deletions
+2 -2
View File
@@ -7,13 +7,13 @@
## Назначение
Продукт предназначен для централизованного контроля состояния рабочих мест,
оценки полезной активности сотрудников, анализа загрузки подразделений,
оценки активности сотрудников, анализа загрузки подразделений,
контроля серверных сервисов, ActivityWatch telemetry, рабочих интервалов,
операторских runbook-проверок и прикладных событий расследования.
## Коммерческие модули
- `DetMir Workforce` - ежедневный управленческий слой: полезная активность,
- `DetMir Workforce` - ежедневный управленческий слой: активность,
активное время, простои, RDP/1C/рабочие приложения, загрузка сотрудников и
отчеты для руководителя.
- `DetMir Security` - прикладной слой ИБ: DLP-сигналы, evidence metadata,
+2 -2
View File
@@ -11,7 +11,7 @@ evidence и Hayabusa используются как прикладные мод
## Назначение
- DetMir Workforce: полезная активность, загрузка сотрудников, RDP/1C/рабочие
- DetMir Workforce: активность сотрудников, загрузка, RDP/1C/рабочие
приложения и управленческие отчеты для владельца бизнеса.
- DetMir Security: DLP-сигналы, evidence, очередь кейсов и audit действий
оператора без заявления продукта как сертифицированной СЗИ.
@@ -42,7 +42,7 @@ runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
## Кому это полезно
- Владельцу и руководителю - видеть полезную активность, загрузку команды,
- Владельцу и руководителю - видеть активность, загрузку команды,
простои, перегрузки и рабочие приложения без просмотра логов.
- ИБ - заметить DLP-сигналы и подозрительную активность.
- Администратору - проверить, что сборщики и сервер работают стабильно.
+286 -6
View File
@@ -64,6 +64,13 @@ struct Cli {
)]
one_c_url: String,
#[arg(
long,
default_value = "/etc/detmir-portal-workforce-policy.json",
env = "DETMIR_PORTAL_WORKFORCE_POLICY_PATH"
)]
workforce_policy_path: PathBuf,
#[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")]
timeout_seconds: u64,
@@ -372,6 +379,37 @@ struct ReportMetrics {
workforce_index: Option<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct WorkforcePolicy {
#[serde(default = "default_workforce_role")]
default_role: String,
#[serde(default)]
roles: BTreeMap<String, WorkforceRolePolicy>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct WorkforceRolePolicy {
#[serde(default)]
label: Option<String>,
#[serde(default)]
planned_hours_per_day: Option<f64>,
#[serde(default)]
default_weight: Option<f64>,
#[serde(default)]
application_weights: BTreeMap<String, f64>,
}
#[derive(Debug)]
struct WeightedActivity {
role: String,
role_label: String,
index: Option<u8>,
planned_seconds: i64,
app_seconds: i64,
weighted_seconds: i64,
matched_applications: usize,
}
fn main() {
let code = match run() {
Ok(code) => code,
@@ -391,7 +429,7 @@ fn run() -> Result<i32> {
let smoke = json!({
"health": build_health(&snapshot),
"summary": build_summary(&snapshot),
"reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args)),
"reports": build_reports(&snapshot, &incident_state, &build_dlp_evidence_response(&args), &args.workforce_policy_path),
"incidents": build_incidents(&snapshot, &incident_state),
"dlp_evidence": build_dlp_evidence_response(&args),
});
@@ -465,7 +503,12 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
let evidence = build_dlp_evidence_response(args);
respond_json(
request,
&build_reports(&snapshot, &incident_state, &evidence),
&build_reports(
&snapshot,
&incident_state,
&evidence,
&args.workforce_policy_path,
),
)
}
"/api/incidents" => {
@@ -841,6 +884,7 @@ fn build_reports(
snapshot: &Snapshot,
incident_state: &IncidentStateFile,
evidence: &DlpEvidenceResponse,
workforce_policy_path: &Path,
) -> Value {
let summary = build_summary(snapshot);
let incidents = build_incidents(snapshot, incident_state);
@@ -868,6 +912,10 @@ fn build_reports(
let worktime = worktime_block(snapshot);
let one_c = one_c_block(snapshot);
let dlp_block_value = dlp_block(snapshot);
let weighted = load_workforce_policy(workforce_policy_path)
.ok()
.flatten()
.and_then(|policy| weighted_activity(snapshot, &policy, metrics.users_count));
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
{
"Контур DetMir работает штатно, критичных действий не требуется"
@@ -906,7 +954,8 @@ fn build_reports(
"headline": headline,
"executive_points": executive_points,
"kpis": [
report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: active time / 8h на сотрудника"),
report_kpi("Индекс активности", workforce_index_text(metrics.workforce_index), workforce_index_status(metrics.workforce_index), "proxy: активное время / плановое рабочее время"),
weighted_activity_kpi(weighted.as_ref()),
report_kpi("Сотрудники", metrics.users_count.to_string(), worktime.status.clone(), "строки worktime за сегодня"),
report_kpi("Активное время", human_duration(metrics.active_seconds), worktime.status.clone(), "сумма active_seconds"),
report_kpi("Приложения", metrics.apps_count.to_string(), worktime.status.clone(), "true active applications"),
@@ -928,6 +977,7 @@ fn build_reports(
"title": "Работа и управляемость",
"items": [
report_item("Индекс активности", workforce_index_status(metrics.workforce_index), workforce_index_text(metrics.workforce_index)),
weighted_activity_item(weighted.as_ref(), workforce_policy_path),
report_item("Worktime", worktime.status.clone(), worktime.text.clone()),
report_item("Активное время", worktime.status.clone(), human_duration(metrics.active_seconds)),
report_item("Приложения", worktime.status.clone(), metrics.apps_count.to_string()),
@@ -948,11 +998,165 @@ fn build_reports(
"items": recommendations.iter().map(|item| report_item("Рекомендация", "INFO", item)).collect::<Vec<_>>()
}
],
"workforce_policy": workforce_policy_json(weighted.as_ref(), workforce_policy_path),
"markdown": markdown,
"links": links()
})
}
fn load_workforce_policy(path: &Path) -> Result<Option<WorkforcePolicy>> {
if !path.exists() {
return Ok(None);
}
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let policy = serde_json::from_str::<WorkforcePolicy>(&data)
.with_context(|| format!("parse {}", path.display()))?;
Ok(Some(policy))
}
fn weighted_activity(
snapshot: &Snapshot,
policy: &WorkforcePolicy,
users_count: usize,
) -> Option<WeightedActivity> {
if users_count == 0 {
return None;
}
let role = policy.default_role.trim();
let role_policy = policy.roles.get(role)?;
let planned_hours = role_policy
.planned_hours_per_day
.unwrap_or(8.0)
.clamp(1.0, 24.0);
let planned_seconds = (users_count as f64 * planned_hours * 3600.0).round() as i64;
if planned_seconds <= 0 {
return None;
}
let default_weight = role_policy.default_weight.unwrap_or(0.0).clamp(0.0, 1.0);
let apps = snapshot
.worktime
.payload
.as_ref()
.and_then(|payload| payload.get("true_active_apps"))
.and_then(Value::as_array)?;
let mut app_seconds = 0_i64;
let mut weighted_seconds = 0_f64;
let mut matched_applications = 0_usize;
for app in apps {
let name = app.get("application").and_then(Value::as_str).unwrap_or("");
let seconds = app
.get("proved_work_seconds")
.and_then(Value::as_i64)
.unwrap_or(0)
.max(0);
if seconds == 0 {
continue;
}
app_seconds += seconds;
let weight = application_weight(role_policy, name, default_weight);
if weight > 0.0 {
matched_applications += 1;
}
weighted_seconds += seconds as f64 * weight;
}
let weighted_seconds_i64 = weighted_seconds.round() as i64;
Some(WeightedActivity {
role: role.to_string(),
role_label: role_policy
.label
.clone()
.unwrap_or_else(|| role.to_string()),
index: Some(
((weighted_seconds / planned_seconds as f64) * 100.0)
.round()
.clamp(0.0, 100.0) as u8,
),
planned_seconds,
app_seconds,
weighted_seconds: weighted_seconds_i64,
matched_applications,
})
}
fn application_weight(
role_policy: &WorkforceRolePolicy,
application: &str,
default_weight: f64,
) -> f64 {
let app = application.to_lowercase();
role_policy
.application_weights
.iter()
.find_map(|(pattern, weight)| {
let pattern = pattern.to_lowercase();
(!pattern.is_empty() && app.contains(&pattern)).then_some(weight.clamp(0.0, 1.0))
})
.unwrap_or(default_weight)
}
fn weighted_activity_kpi(weighted: Option<&WeightedActivity>) -> Value {
match weighted {
Some(weighted) => report_kpi(
"Взвешенная активность",
workforce_index_text(weighted.index),
workforce_index_status(weighted.index),
&format!("role={} по весам приложений", weighted.role_label),
),
None => report_kpi(
"Взвешенная активность",
"не настроена".to_string(),
"UNKNOWN".to_string(),
"нужен workforce policy с весами приложений",
),
}
}
fn weighted_activity_item(weighted: Option<&WeightedActivity>, policy_path: &Path) -> Value {
match weighted {
Some(weighted) => report_item(
"Взвешенная активность",
workforce_index_status(weighted.index),
format!(
"{}; роль {}; weighted {}; apps {}",
workforce_index_text(weighted.index),
weighted.role_label,
human_duration(weighted.weighted_seconds),
weighted.matched_applications
),
),
None => report_item(
"Взвешенная активность",
"UNKNOWN",
format!("policy не настроена: {}", policy_path.display()),
),
}
}
fn workforce_policy_json(weighted: Option<&WeightedActivity>, policy_path: &Path) -> Value {
match weighted {
Some(weighted) => json!({
"configured": true,
"path": policy_path.display().to_string(),
"role": weighted.role,
"role_label": weighted.role_label,
"index": weighted.index,
"planned_seconds": weighted.planned_seconds,
"app_seconds": weighted.app_seconds,
"weighted_seconds": weighted.weighted_seconds,
"matched_applications": weighted.matched_applications,
}),
None => json!({
"configured": false,
"path": policy_path.display().to_string(),
"note": "weighted activity requires role/application policy",
}),
}
}
fn default_workforce_role() -> String {
"default".to_string()
}
fn report_kpi(label: &str, value: String, status: String, context: &str) -> Value {
json!({
"label": label,
@@ -992,7 +1196,7 @@ fn render_report_markdown(
}
));
text.push_str(&format!(
"- Индекс полезной активности: {}\n",
"- Индекс активности: {}\n",
workforce_index_text(metrics.workforce_index)
));
text.push_str(&format!(
@@ -2566,6 +2770,7 @@ mod tests {
failed_units_cmd: "true".to_string(),
worktime_url: "http://127.0.0.1".to_string(),
one_c_url: "http://127.0.0.1".to_string(),
workforce_policy_path: dir.path().join("workforce-policy.json"),
timeout_seconds: 1,
state_dir: dir.path().join("state"),
dlp_db_path: dir.path().join("dlp.sqlite"),
@@ -2686,7 +2891,8 @@ mod tests {
{"user": "USER-2", "active_seconds": 1800}
],
"true_active_apps": [
{"application": "ERP", "proved_work_human": "00:30"}
{"application": "ERP", "proved_work_human": "00:30", "proved_work_seconds": 3600},
{"application": "Browser", "proved_work_human": "00:30", "proved_work_seconds": 3600}
]
})),
},
@@ -2731,7 +2937,13 @@ mod tests {
}],
error: None,
};
let report = build_reports(&snapshot, &IncidentStateFile::default(), &evidence);
let missing_policy = Path::new("/tmp/detmir-missing-workforce-policy.json");
let report = build_reports(
&snapshot,
&IncidentStateFile::default(),
&evidence,
missing_policy,
);
assert_eq!(report["operator_ok"], true);
assert_eq!(report["severity"], "OK");
assert!(
@@ -2741,5 +2953,73 @@ mod tests {
.contains("derived detections/cases")
);
assert!(report["kpis"].as_array().unwrap().len() >= 6);
assert_eq!(report["workforce_policy"]["configured"], false);
}
#[test]
fn weighted_activity_uses_role_application_policy() {
let snapshot = Snapshot {
generated_at_utc: "2026-06-03T10:00:00Z".to_string(),
detmir_status: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({})),
},
detmir_check: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({})),
},
failed_units: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: None,
},
worktime: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: Some(json!({
"true_active_apps": [
{"application": "1С", "proved_work_seconds": 3600},
{"application": "YouTube", "proved_work_seconds": 3600}
]
})),
},
one_c: SourceStatus {
ok: true,
status: "OK".to_string(),
summary: "".to_string(),
error: None,
payload: None,
},
};
let policy = WorkforcePolicy {
default_role: "accountant".to_string(),
roles: BTreeMap::from([(
"accountant".to_string(),
WorkforceRolePolicy {
label: Some("Бухгалтер".to_string()),
planned_hours_per_day: Some(8.0),
default_weight: Some(0.2),
application_weights: BTreeMap::from([
("1с".to_string(), 1.0),
("youtube".to_string(), 0.0),
]),
},
)]),
};
let weighted = weighted_activity(&snapshot, &policy, 1).unwrap();
assert_eq!(weighted.role, "accountant");
assert_eq!(weighted.weighted_seconds, 3600);
assert_eq!(weighted.app_seconds, 7200);
assert_eq!(weighted.index, Some(13));
}
}
@@ -125,7 +125,7 @@ function renderManager(data) {
<section class="card">
<h3>Работа сегодня</h3>
<p class="kpi-value">${escapeHtml(workforceIndex)}</p>
<p class="muted">Индекс полезной активности: active time / 8 ч на сотрудника</p>
<p class="muted">proxy: активное время / плановое рабочее время</p>
<p class="muted">Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}</p>
<p class="muted">${escapeHtml(data.status?.text || "")}</p>
</section>
+11
View File
@@ -12,6 +12,7 @@
detmir_portal_worktime_url: "{{ detmir_portal_worktime_url_override | default(aw_worktime_report_base | default('http://192.0.2.13:5610', true), true) }}"
detmir_portal_one_c_host: "{{ hostvars[(groups['proxmox'] | default([]) | first) | default('192.0.2.2', true)].ansible_host | default((groups['proxmox'] | default([]) | first) | default('192.0.2.2', true), true) }}"
detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}"
detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json"
tasks:
- name: Check local detmir-portal binary
@@ -47,6 +48,7 @@
DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager
DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }}
DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }}
DETMIR_PORTAL_WORKFORCE_POLICY_PATH={{ detmir_portal_workforce_policy_path }}
DETMIR_PORTAL_TIMEOUT_SECONDS=10
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite
@@ -54,6 +56,15 @@
DETMIR_PORTAL_EVIDENCE_LIMIT=30
DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608
- name: Install initial workforce policy when absent
ansible.builtin.copy:
dest: "{{ detmir_portal_workforce_policy_path }}"
owner: root
group: root
mode: "0644"
force: false
content: "{{ lookup('file', aw_repo_root + '/configs/detmir-workforce-policy.example.json') }}"
- name: Ensure detmir-portal state and evidence directories
ansible.builtin.file:
path: "{{ item }}"
@@ -0,0 +1,44 @@
{
"default_role": "accountant",
"roles": {
"accountant": {
"label": "Бухгалтер",
"planned_hours_per_day": 8,
"default_weight": 0.2,
"application_weights": {
"1с": 1.0,
"excel": 0.9,
"word": 0.7,
"rdp": 0.8,
"browser": 0.4,
"youtube": 0.0
}
},
"sales": {
"label": "Продажи",
"planned_hours_per_day": 8,
"default_weight": 0.3,
"application_weights": {
"crm": 1.0,
"1с": 0.8,
"browser": 0.8,
"mail": 0.8,
"telegram": 0.4,
"youtube": 0.0
}
},
"developer": {
"label": "Разработчик",
"planned_hours_per_day": 8,
"default_weight": 0.3,
"application_weights": {
"ide": 1.0,
"code": 1.0,
"terminal": 0.9,
"browser": 0.7,
"git": 0.9,
"youtube": 0.2
}
}
}
}
+41 -3
View File
@@ -21,14 +21,52 @@ Forensics усиливают продукт, но не должны перетя
Основные KPI:
- полезная активность;
- индекс активности: proxy `активное время / плановое рабочее время`;
- взвешенная активность: только при настроенной role/application policy;
- активное время;
- простой;
- число рабочих сессий;
- активные приложения;
- индекс полезной активности;
- документы/операции 1С при наличии связанного бизнес-слоя.
Важно: термин "полезная активность" нельзя использовать для простого proxy по
времени. Он допустим только для будущего/настроенного слоя, где приложения
взвешены по ролям: например, для бухгалтера `1C` имеет высокий вес, а для
маркетолога браузер и соцсети могут быть частью рабочей активности.
### Настройка весов приложений
Публичный пример:
- `configs/detmir-workforce-policy.example.json`
Runtime-файл на сервере портала:
- `/etc/detmir-portal-workforce-policy.json`
Правило:
- `default_role` задает роль для агрегированного отчета;
- `planned_hours_per_day` задает плановый рабочий день для роли;
- `default_weight` применяется к приложениям без явного правила;
- `application_weights` задает веса приложений по подстроке имени.
Пример логики:
- бухгалтер: `1C = 1.0`, `Excel = 0.9`, `YouTube = 0.0`;
- продажи: `CRM = 1.0`, `browser = 0.8`, `mail = 0.8`;
- разработчик: `IDE/code = 1.0`, `terminal = 0.9`, `browser = 0.7`.
После изменения runtime-файла нужно перезапустить портал:
```bash
sudo systemctl restart detmir-portal.service
```
Если policy-файл отсутствует, портал показывает только нейтральный
`Индекс активности`. `Взвешенная активность` появляется только после настройки
role/application policy.
Корректная формулировка для продажи:
> DetMir помогает руководителю видеть загрузку сотрудников и бизнес-процессов
@@ -69,7 +107,7 @@ Forensics усиливают продукт, но не должны перетя
Порядок показа владельцу бизнеса:
1. Workforce: полезная активность, загрузка, RDP/1С, рабочие приложения.
1. Workforce: индекс активности, загрузка, RDP/1С, рабочие приложения.
2. Commercial reports: ежедневный срез, KPI, Markdown/HTML отчет.
3. Security: DLP-сигналы и evidence.
4. Forensics: цепочка расследования, Hayabusa, кейсы.
+7 -2
View File
@@ -30,8 +30,13 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
- UI tab: `Отчеты`;
- отчет содержит KPI для владельца/руководителя: worktime users, active time,
active applications, DLP WARN/FAIL, evidence screenshots/items, open issues;
- отчет и вкладка `Руководитель` показывают `Индекс полезной активности` как
proxy `active time / 8h на сотрудника`;
- отчет и вкладка `Руководитель` показывают `Индекс активности` как
proxy `активное время / плановое рабочее время`;
- отчет поддерживает `Взвешенную активность` при наличии
`/etc/detmir-portal-workforce-policy.json`; публичный пример лежит в
`configs/detmir-workforce-policy.example.json`;
- Ansible устанавливает initial workforce policy только если runtime-файл
отсутствует, чтобы не перетирать клиентские веса ролей;
- отчет содержит Markdown export для передачи руководителю или заказчику;
- формулировка DLP/case показателей зафиксирована как
`derived detections/cases`, не как вручную подтвержденные инциденты;
+2 -2
View File
@@ -6,7 +6,7 @@
## 1. DetMir Workforce: трудоотдача и загрузка
Первый коммерческий экран для владельца и руководителя. Показывает не
"сидел за компьютером", а полезную активность: кто реально работал, кто
"сидел за компьютером", а активность: кто реально работал, кто
перегружен, кто простаивает, сколько времени уходит в RDP, 1C и рабочие
приложения.
@@ -99,7 +99,7 @@ Live endpoint:
## Рекомендуемый порядок демонстрации
1. `DetMir Workforce` - полезная активность, загрузка, RDP/1C и рабочие
1. `DetMir Workforce` - активность, загрузка, RDP/1C и рабочие
приложения.
2. `RDP Worktime Report` - отдельный генерируемый per-user отчет как
доказательство реальной работы сотрудников.