feat(workforce): add interpretation policy config

This commit is contained in:
igor04091968
2026-06-03 13:03:24 +03:00
parent 0302d888f6
commit 7bc1d4170a
7 changed files with 235 additions and 12 deletions
+187 -12
View File
@@ -9,7 +9,8 @@ use std::{
use anyhow::{Context, Result, anyhow};
use chrono::{
DateTime, Datelike, FixedOffset, Local, NaiveDate, SecondsFormat, TimeDelta, TimeZone, Utc,
DateTime, Datelike, FixedOffset, Local, NaiveDate, NaiveTime, SecondsFormat, TimeDelta,
TimeZone, Utc,
};
use clap::Parser;
use reqwest::{
@@ -48,6 +49,9 @@ struct Config {
manager_trend_min_points: usize,
manager_trend_delta_pct: f64,
manager_off_hours_threshold_seconds: i64,
manager_night_work_after: Option<NaiveTime>,
manager_weekend_work_enabled: bool,
manager_interpretation_policy_configured: bool,
manager_late_start_grace_minutes: i64,
manager_early_finish_grace_minutes: i64,
manager_critical_source_max_age_seconds: i64,
@@ -102,6 +106,24 @@ struct AliasProfile {
exclude: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct InterpretationPolicy {
#[serde(default)]
overload_threshold: Option<f64>,
#[serde(default)]
underload_threshold: Option<f64>,
#[serde(default)]
drop_threshold_pct: Option<f64>,
#[serde(default)]
night_work_after: Option<String>,
#[serde(default)]
weekend_work: Option<bool>,
#[serde(default)]
min_trend_points: Option<usize>,
#[serde(default)]
off_hours_threshold_seconds: Option<i64>,
}
#[derive(Debug, Clone)]
struct CachedResponse {
stored: Instant,
@@ -154,6 +176,18 @@ fn env_usize(name: &str, fallback: usize) -> usize {
.unwrap_or(fallback)
}
fn env_bool(name: &str, fallback: bool) -> bool {
std::env::var(name)
.ok()
.map(|value| value.trim().to_lowercase())
.and_then(|value| match value.as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
})
.unwrap_or(fallback)
}
fn build_aw_api_base(raw_url: &str) -> String {
let url = raw_url.trim().trim_end_matches('/');
if url.ends_with("/api/0") {
@@ -163,16 +197,74 @@ fn build_aw_api_base(raw_url: &str) -> String {
}
}
fn load_interpretation_policy(path: &Path) -> Option<InterpretationPolicy> {
if !path.exists() {
return None;
}
serde_json::from_str::<InterpretationPolicy>(&fs::read_to_string(path).ok()?).ok()
}
fn threshold_to_pct(value: f64) -> f64 {
if (0.0..=1.0).contains(&value) {
value * 100.0
} else {
value
}
}
fn parse_hhmm(value: &str) -> Option<NaiveTime> {
NaiveTime::parse_from_str(value.trim(), "%H:%M").ok()
}
fn load_config() -> Config {
let default_sample_seconds = env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", 30.0).max(1.0);
let max_sample_seconds =
env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", 300.0).max(default_sample_seconds);
let manager_interpretation_policy_path = PathBuf::from(env(
"AW_WORKTIME_MANAGER_INTERPRETATION_POLICY",
"/etc/activitywatch/worktime-interpretation-policy.json",
));
let interpretation_policy = load_interpretation_policy(&manager_interpretation_policy_path);
let manager_target_coverage_pct =
env_i64("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", 75).clamp(1, 100);
let manager_low_coverage_pct =
let mut manager_low_coverage_pct =
env_i64("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", 35).clamp(1, 100);
let manager_overload_coverage_pct =
let mut manager_overload_coverage_pct =
env_i64("AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT", 115).clamp(100, 300);
let mut manager_trend_min_points =
env_usize("AW_WORKTIME_MANAGER_TREND_MIN_POINTS", 3).clamp(2, 31);
let mut manager_trend_delta_pct =
env_f64("AW_WORKTIME_MANAGER_TREND_DELTA_PCT", 10.0).clamp(1.0, 100.0);
let mut manager_off_hours_threshold_seconds =
env_i64("AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS", 1800).max(60);
let mut manager_night_work_after =
parse_hhmm(&env("AW_WORKTIME_MANAGER_NIGHT_WORK_AFTER", "20:00"));
let mut manager_weekend_work_enabled =
env_bool("AW_WORKTIME_MANAGER_WEEKEND_WORK_ENABLED", true);
if let Some(policy) = interpretation_policy.as_ref() {
if let Some(value) = policy.underload_threshold {
manager_low_coverage_pct = threshold_to_pct(value).round().clamp(1.0, 100.0) as i64;
}
if let Some(value) = policy.overload_threshold {
manager_overload_coverage_pct =
threshold_to_pct(value).round().clamp(1.0, 300.0) as i64;
}
if let Some(value) = policy.drop_threshold_pct {
manager_trend_delta_pct = threshold_to_pct(value).clamp(1.0, 100.0);
}
if let Some(value) = policy.min_trend_points {
manager_trend_min_points = value.clamp(2, 31);
}
if let Some(value) = policy.off_hours_threshold_seconds {
manager_off_hours_threshold_seconds = value.max(60);
}
if let Some(value) = policy.night_work_after.as_deref().and_then(parse_hhmm) {
manager_night_work_after = Some(value);
}
if let Some(value) = policy.weekend_work {
manager_weekend_work_enabled = value;
}
}
let report_cache_ttl_seconds = env_i64("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", 60).max(0);
let report_stale_ttl_seconds =
env_i64("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", 900).max(report_cache_ttl_seconds);
@@ -189,14 +281,12 @@ fn load_config() -> Config {
manager_target_coverage_pct,
manager_low_coverage_pct,
manager_overload_coverage_pct,
manager_trend_min_points: env_usize("AW_WORKTIME_MANAGER_TREND_MIN_POINTS", 3).clamp(2, 31),
manager_trend_delta_pct: env_f64("AW_WORKTIME_MANAGER_TREND_DELTA_PCT", 10.0)
.clamp(1.0, 100.0),
manager_off_hours_threshold_seconds: env_i64(
"AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS",
1800,
)
.max(60),
manager_trend_min_points,
manager_trend_delta_pct,
manager_off_hours_threshold_seconds,
manager_night_work_after,
manager_weekend_work_enabled,
manager_interpretation_policy_configured: interpretation_policy.is_some(),
manager_late_start_grace_minutes: env_i64(
"AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES",
60,
@@ -1540,6 +1630,16 @@ impl App {
"trend": trend,
"trend_scope": "portfolio",
"trend_insights": insights,
"interpretation_policy": {
"configured": self.config.manager_interpretation_policy_configured,
"overload_threshold": self.config.manager_overload_coverage_pct as f64 / 100.0,
"underload_threshold": self.config.manager_low_coverage_pct as f64 / 100.0,
"drop_threshold_pct": self.config.manager_trend_delta_pct,
"night_work_after": self.config.manager_night_work_after.map(|time| time.format("%H:%M").to_string()),
"weekend_work": self.config.manager_weekend_work_enabled,
"min_trend_points": self.config.manager_trend_min_points,
"off_hours_threshold_seconds": self.config.manager_off_hours_threshold_seconds
},
"history": {
"enabled": true,
"saved_current_point": history_saved,
@@ -2373,6 +2473,9 @@ fn add_off_hours_insights(
report_date.weekday(),
chrono::Weekday::Sat | chrono::Weekday::Sun
);
if is_weekend && !config.manager_weekend_work_enabled {
return;
}
if is_weekend && calendar_total >= config.manager_off_hours_threshold_seconds {
insights.push(insight(
"weekend_work",
@@ -2386,7 +2489,33 @@ fn add_off_hours_insights(
),
"Проверить, была ли работа согласована и не маскирует ли она аврал или сбой графика.",
));
} else if off_hours >= config.manager_off_hours_threshold_seconds {
return;
}
if let Some(night_after) = config.manager_night_work_after {
let night_users = rows
.iter()
.filter(|row| {
row_local_time(row, "last_activity_local").is_some_and(|time| time >= night_after)
})
.count();
if night_users > 0 && off_hours >= config.manager_off_hours_threshold_seconds {
insights.push(insight(
"night_work",
"WARN",
"portfolio",
"Workforce",
"Есть ночная работа",
&format!(
"После {} зафиксирована активность; сотрудников: {}; вне рабочего окна: {}.",
night_after.format("%H:%M"),
night_users,
hhmm(off_hours)
),
"Проверить согласование ночной работы, авралы и корректность графика.",
));
}
}
if off_hours >= config.manager_off_hours_threshold_seconds {
let users = rows
.iter()
.filter(|row| {
@@ -2417,6 +2546,13 @@ fn add_off_hours_insights(
}
}
fn row_local_time(row: &Value, key: &str) -> Option<NaiveTime> {
row.get(key)
.and_then(Value::as_str)
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
.map(|dt| dt.time())
}
fn insight(
code: &str,
severity: &str,
@@ -3225,6 +3361,45 @@ mod tests {
);
}
#[test]
fn interpretation_policy_accepts_fraction_thresholds() {
let policy: InterpretationPolicy = serde_json::from_value(json!({
"overload_threshold": 0.92,
"underload_threshold": 0.45,
"drop_threshold_pct": 20,
"night_work_after": "20:00",
"weekend_work": true
}))
.unwrap();
assert_eq!(threshold_to_pct(policy.overload_threshold.unwrap()), 92.0);
assert_eq!(threshold_to_pct(policy.underload_threshold.unwrap()), 45.0);
assert_eq!(threshold_to_pct(policy.drop_threshold_pct.unwrap()), 20.0);
assert_eq!(
parse_hhmm(policy.night_work_after.as_deref().unwrap()).unwrap(),
NaiveTime::from_hms_opt(20, 0, 0).unwrap()
);
assert_eq!(policy.weekend_work, Some(true));
}
#[test]
fn weekend_policy_can_disable_weekend_work_insight() {
let mut cfg = test_config();
cfg.manager_weekend_work_enabled = false;
let insights = build_management_insights(
&cfg,
&json!({
"calendar_total_active_seconds": 7200,
"workday_total_active_seconds": 0
}),
&[],
&[],
&[],
&[],
NaiveDate::from_ymd_opt(2026, 5, 16).unwrap(),
);
assert!(!insights.iter().any(|item| item["code"] == "weekend_work"));
}
#[test]
fn true_active_apps_require_evidence() {
let cfg = test_config();
+10
View File
@@ -666,6 +666,7 @@
AW_WORKTIME_MANAGER_TREND_MIN_POINTS={{ aw_worktime_manager_trend_min_points | default(3) }}
AW_WORKTIME_MANAGER_TREND_DELTA_PCT={{ aw_worktime_manager_trend_delta_pct | default(10) }}
AW_WORKTIME_MANAGER_OFF_HOURS_THRESHOLD_SECONDS={{ aw_worktime_manager_off_hours_threshold_seconds | default(1800) }}
AW_WORKTIME_MANAGER_INTERPRETATION_POLICY={{ aw_worktime_interpretation_policy_path | default('/etc/activitywatch/worktime-interpretation-policy.json') }}
AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }}
AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }}
AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }}
@@ -1098,6 +1099,15 @@
group: "{{ aw_server_group }}"
mode: "0750"
- name: Установить initial policy интерпретации Workforce если отсутствует
ansible.builtin.copy:
dest: "{{ aw_worktime_interpretation_policy_path | default('/etc/activitywatch/worktime-interpretation-policy.json') }}"
owner: root
group: root
mode: "0644"
force: false
content: "{{ lookup('file', aw_repo_root + '/configs/worktime-interpretation-policy.example.json') }}"
- name: Проверить локальный Rust AW worktime API
ansible.builtin.stat:
path: "{{ aw_rust_release_dir }}/worktime-api"
+1
View File
@@ -26,6 +26,7 @@ aw_worktime_manager_overload_coverage_pct: 115
aw_worktime_manager_trend_min_points: 3
aw_worktime_manager_trend_delta_pct: 10
aw_worktime_manager_off_hours_threshold_seconds: 1800
aw_worktime_interpretation_policy_path: "/etc/activitywatch/worktime-interpretation-policy.json"
aw_dlp_influx_enabled: false
aw_dlp_influx_url: "http://<INFLUXDB_HOST>:8086"
aw_dlp_influx_org: "proxmox"
+1
View File
@@ -26,6 +26,7 @@ aw_worktime_manager_overload_coverage_pct: 115
aw_worktime_manager_trend_min_points: 3
aw_worktime_manager_trend_delta_pct: 10
aw_worktime_manager_off_hours_threshold_seconds: 1800
aw_worktime_interpretation_policy_path: "/etc/activitywatch/worktime-interpretation-policy.json"
aw_dlp_influx_enabled: true
aw_dlp_influx_url: "http://192.0.2.10:8086"
aw_dlp_influx_org: "proxmox"
@@ -0,0 +1,9 @@
{
"overload_threshold": 0.92,
"underload_threshold": 0.45,
"drop_threshold_pct": 20,
"night_work_after": "20:00",
"weekend_work": true,
"min_trend_points": 3,
"off_hours_threshold_seconds": 1800
}
+24
View File
@@ -85,6 +85,30 @@ Worktime API сохраняет daily history как агрегированны
- `AW_WORKTIME_MANAGEMENT_HISTORY_DIR`;
- `AW_WORKTIME_MANAGEMENT_HISTORY_DAYS`;
- `AW_WORKTIME_MANAGEMENT_HISTORY_RETENTION_DAYS`.
Интерпретация трендов настраивается через customer policy:
- пример: `configs/worktime-interpretation-policy.example.json`;
- runtime-файл: `/etc/activitywatch/worktime-interpretation-policy.json`;
- env-путь: `AW_WORKTIME_MANAGER_INTERPRETATION_POLICY`.
Пример policy:
```json
{
"overload_threshold": 0.92,
"underload_threshold": 0.45,
"drop_threshold_pct": 20,
"night_work_after": "20:00",
"weekend_work": true
}
```
`overload_threshold` и `underload_threshold` можно задавать дробью
`0.92`/`0.45` или процентом `92`/`45`; внутри они нормализуются к процентам.
Если policy-файл отсутствует или отдельное поле не задано, используются
env/default значения:
- `AW_WORKTIME_MANAGER_OVERLOAD_COVERAGE_PCT`;
- `AW_WORKTIME_MANAGER_TREND_MIN_POINTS`;
- `AW_WORKTIME_MANAGER_TREND_DELTA_PCT`;
+3
View File
@@ -45,6 +45,9 @@ baseline и раздела `Phase 8: Post-MVP Enhancements`.
`Выводы Workforce`;
- insights включают текущую недогрузку/перегрузку, рост/падение тренда,
просадку относительно нормы, работу вне рабочего окна и выходные;
- пороги интерпретации задаются customer policy
`/etc/activitywatch/worktime-interpretation-policy.json`, публичный пример:
`configs/worktime-interpretation-policy.example.json`;
- `trend_status=daily_only` означает, что месячный тренд еще нельзя
интерпретировать как полноценный отчет; для `monthly_ready` нужна накопленная
daily history;