feat(scoring): composite recency decay and severity weights persisted in DB [FASE-5.1]

This commit is contained in:
2026-05-18 15:07:12 +02:00
parent 2ee59d4e18
commit 05b221a22d
13 changed files with 588 additions and 154 deletions

View File

@@ -9,7 +9,8 @@ number of SQL queries regardless of technique count.
import logging
import uuid
from datetime import datetime
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import func
from sqlalchemy.orm import Session
@@ -43,6 +44,11 @@ def serialize_snapshot_summary(snap: CoverageSnapshot) -> dict:
"not_covered_count": snap.not_covered_count,
"in_progress_count": snap.in_progress_count,
"not_evaluated_count": snap.not_evaluated_count,
"coverage_percentage": getattr(snap, "coverage_percentage", 0.0),
"by_tactic": getattr(snap, "by_tactic", None) or {},
"by_status": getattr(snap, "by_status", None) or {},
"stale_count": getattr(snap, "stale_count", 0),
"never_tested_count": getattr(snap, "never_tested_count", 0),
"created_by": str(snap.created_by) if snap.created_by else None,
"created_at": snap.created_at.isoformat() if snap.created_at else None,
}
@@ -148,6 +154,13 @@ def create_snapshot(
not_covered_count = 0
in_progress_count = 0
not_evaluated_count = 0
stale_count = 0
never_tested_count = 0
by_tactic: dict[str, dict] = defaultdict(
lambda: {"total": 0, "validated": 0, "partial": 0, "score_sum": 0.0}
)
by_status: dict[str, int] = defaultdict(int)
technique_rows: list[dict] = []
@@ -170,15 +183,43 @@ def create_snapshot(
not_evaluated_count += 1
entry = scores_map.get(tech.id, {})
score = entry.get("total_score", 0)
technique_rows.append({
"technique_id": tech.id,
"mitre_id": tech.mitre_id,
"status": status_value,
"score": entry.get("total_score", 0),
"score": score,
})
by_status[status_value] += 1
tactic_key = tech.tactic or "unknown"
bucket = by_tactic[tactic_key]
bucket["total"] += 1
bucket["score_sum"] += score
if status_value == "validated":
bucket["validated"] += 1
elif status_value == "partial":
bucket["partial"] += 1
if status_value == "not_evaluated":
never_tested_count += 1
if tech.review_required:
stale_count += 1
org_data = calculate_organization_score(db)
org_score = org_data.get("overall_score", 0)
total_techniques = len(techniques) or 1
coverage_pct = round((validated_count / total_techniques) * 100, 1)
by_tactic_out = {
tactic: {
"total": data["total"],
"validated": data["validated"],
"partial": data["partial"],
"average_score": round(data["score_sum"] / data["total"], 1) if data["total"] else 0,
}
for tactic, data in by_tactic.items()
}
snapshot = CoverageSnapshot(
name=name,
@@ -189,6 +230,11 @@ def create_snapshot(
not_covered_count=not_covered_count,
in_progress_count=in_progress_count,
not_evaluated_count=not_evaluated_count,
coverage_percentage=coverage_pct,
by_tactic=by_tactic_out,
by_status=dict(by_status),
stale_count=stale_count,
never_tested_count=never_tested_count,
created_by=user_id,
)
db.add(snapshot)
@@ -320,6 +366,37 @@ def compare_snapshots(
}
# ---------------------------------------------------------------------------
# Coverage evolution (trends)
# ---------------------------------------------------------------------------
def get_coverage_evolution(db: Session, *, months: int = 12) -> list[dict]:
"""Return snapshot trend points for the last *months* months."""
cutoff = datetime.now(timezone.utc) - timedelta(days=months * 30)
snapshots = (
db.query(CoverageSnapshot)
.filter(CoverageSnapshot.created_at >= cutoff)
.order_by(CoverageSnapshot.created_at.asc())
.all()
)
return [
{
"date": snap.created_at.isoformat() if snap.created_at else None,
"name": snap.name,
"org_score": snap.organization_score,
"coverage_pct": getattr(snap, "coverage_percentage", 0.0),
"by_tactic": getattr(snap, "by_tactic", None) or {},
"by_status": getattr(snap, "by_status", None) or {},
"stale_count": getattr(snap, "stale_count", 0),
"never_tested_count": getattr(snap, "never_tested_count", 0),
"validated_count": snap.validated_count,
"total_techniques": snap.total_techniques,
}
for snap in snapshots
]
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------