refactor(scoring): persist weights in DB table, replace mutable Settings with scoring_config_service
This commit is contained in:
107
backend/app/services/scoring_config_service.py
Normal file
107
backend/app/services/scoring_config_service.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Scoring configuration persistence service.
|
||||
|
||||
Reads and writes scoring weights from the ``scoring_config`` table.
|
||||
Falls back to environment-variable defaults (from ``Settings``) when
|
||||
no row has been persisted yet.
|
||||
|
||||
This module is framework-agnostic: no FastAPI imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.domain.value_objects.scoring_weights import ScoringWeights
|
||||
from app.models.scoring_config import ScoringConfig
|
||||
|
||||
|
||||
def get_scoring_weights(db: Session) -> ScoringWeights:
|
||||
"""Return the active scoring weights.
|
||||
|
||||
Reads the single ``scoring_config`` row. If the table is empty
|
||||
(first run or migration just applied), falls back to the values
|
||||
from the environment / ``Settings``.
|
||||
"""
|
||||
row = db.query(ScoringConfig).first()
|
||||
if row is not None:
|
||||
return ScoringWeights(
|
||||
tests=row.weight_tests,
|
||||
detection_rules=row.weight_detection_rules,
|
||||
d3fend=row.weight_d3fend,
|
||||
freshness=row.weight_freshness,
|
||||
platform_diversity=row.weight_platform_diversity,
|
||||
)
|
||||
|
||||
return ScoringWeights(
|
||||
tests=float(settings.SCORING_WEIGHT_TESTS),
|
||||
detection_rules=float(settings.SCORING_WEIGHT_DETECTION_RULES),
|
||||
d3fend=float(settings.SCORING_WEIGHT_D3FEND),
|
||||
freshness=float(settings.SCORING_WEIGHT_FRESHNESS),
|
||||
platform_diversity=float(settings.SCORING_WEIGHT_PLATFORM_DIVERSITY),
|
||||
)
|
||||
|
||||
|
||||
def update_scoring_weights(
|
||||
db: Session,
|
||||
*,
|
||||
tests: float | None = None,
|
||||
detection_rules: float | None = None,
|
||||
d3fend: float | None = None,
|
||||
freshness: float | None = None,
|
||||
platform_diversity: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert scoring weights into the database.
|
||||
|
||||
Only provided fields are overwritten; ``None`` values keep the
|
||||
current (or default) value. Validates via ``ScoringWeights``
|
||||
before persisting.
|
||||
|
||||
Returns a dict with ``weights`` and ``total``.
|
||||
"""
|
||||
current = get_scoring_weights(db)
|
||||
|
||||
new = ScoringWeights(
|
||||
tests=tests if tests is not None else current.tests,
|
||||
detection_rules=detection_rules if detection_rules is not None else current.detection_rules,
|
||||
d3fend=d3fend if d3fend is not None else current.d3fend,
|
||||
freshness=freshness if freshness is not None else current.freshness,
|
||||
platform_diversity=platform_diversity if platform_diversity is not None else current.platform_diversity,
|
||||
)
|
||||
|
||||
row = db.query(ScoringConfig).first()
|
||||
if row is None:
|
||||
row = ScoringConfig()
|
||||
db.add(row)
|
||||
|
||||
row.weight_tests = new.tests
|
||||
row.weight_detection_rules = new.detection_rules
|
||||
row.weight_d3fend = new.d3fend
|
||||
row.weight_freshness = new.freshness
|
||||
row.weight_platform_diversity = new.platform_diversity
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
|
||||
return _weights_dict(new)
|
||||
|
||||
|
||||
def get_weights_dict(db: Session) -> dict[str, Any]:
|
||||
"""Return current weights as a serialisable dict."""
|
||||
return _weights_dict(get_scoring_weights(db))
|
||||
|
||||
|
||||
def _weights_dict(w: ScoringWeights) -> dict[str, Any]:
|
||||
weights = {
|
||||
"tests": w.tests,
|
||||
"detection_rules": w.detection_rules,
|
||||
"d3fend": w.d3fend,
|
||||
"freshness": w.freshness,
|
||||
"platform_diversity": w.platform_diversity,
|
||||
}
|
||||
return {
|
||||
"weights": weights,
|
||||
"total": sum(weights.values()),
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Scoring service — granular 0-100 scoring for techniques, tactics, actors, and org.
|
||||
|
||||
Uses configurable weights from Settings to compute coverage scores with
|
||||
detailed breakdowns.
|
||||
Reads configurable weights from the ``scoring_config`` table (falling
|
||||
back to env-var defaults) to compute coverage scores with detailed
|
||||
breakdowns.
|
||||
|
||||
Bulk helpers (``bulk_technique_scores``) pre-fetch all scoring data in a
|
||||
fixed number of aggregated queries so that organisation-wide calculations
|
||||
@@ -14,7 +15,6 @@ from typing import Optional
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.detection_rule import DetectionRule
|
||||
@@ -22,20 +22,12 @@ from app.models.test_detection_result import TestDetectionResult
|
||||
from app.models.defensive_technique import DefensiveTechniqueMapping
|
||||
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||
from app.models.enums import TestState, TestResult
|
||||
from app.services.scoring_config_service import get_scoring_weights
|
||||
|
||||
|
||||
# ── Bulk scoring helpers (5 queries for ALL techniques) ───────────────
|
||||
|
||||
|
||||
def _build_empty_stats():
|
||||
return {
|
||||
"validated": 0,
|
||||
"detected": 0,
|
||||
"platforms": set(),
|
||||
"latest_validated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def bulk_technique_scores(db: Session) -> dict:
|
||||
"""Pre-fetch all scoring data and compute per-technique scores in memory.
|
||||
|
||||
@@ -48,11 +40,12 @@ def bulk_technique_scores(db: Session) -> dict:
|
||||
|
||||
Returns ``{technique_id: {"total_score": float, "breakdown": dict}}``.
|
||||
"""
|
||||
w_tests = settings.SCORING_WEIGHT_TESTS
|
||||
w_detection = settings.SCORING_WEIGHT_DETECTION_RULES
|
||||
w_d3fend = settings.SCORING_WEIGHT_D3FEND
|
||||
w_freshness = settings.SCORING_WEIGHT_FRESHNESS
|
||||
w_diversity = settings.SCORING_WEIGHT_PLATFORM_DIVERSITY
|
||||
w = get_scoring_weights(db)
|
||||
w_tests = w.tests
|
||||
w_detection = w.detection_rules
|
||||
w_d3fend = w.d3fend
|
||||
w_freshness = w.freshness
|
||||
w_diversity = w.platform_diversity
|
||||
|
||||
# Q1: test stats grouped by technique_id
|
||||
test_rows = (
|
||||
@@ -242,18 +235,14 @@ def bulk_technique_scores(db: Session) -> dict:
|
||||
def calculate_technique_score(technique: Technique, db: Session) -> dict:
|
||||
"""Calculate a 0-100 score for a technique with detailed breakdown.
|
||||
|
||||
Weights (configurable via settings):
|
||||
- tests_validated: weight from SCORING_WEIGHT_TESTS
|
||||
- detection_rules: weight from SCORING_WEIGHT_DETECTION_RULES
|
||||
- d3fend_coverage: weight from SCORING_WEIGHT_D3FEND
|
||||
- freshness: weight from SCORING_WEIGHT_FRESHNESS
|
||||
- platform_diversity: weight from SCORING_WEIGHT_PLATFORM_DIVERSITY
|
||||
Weights are read from the ``scoring_config`` table (or env defaults).
|
||||
"""
|
||||
w_tests = settings.SCORING_WEIGHT_TESTS
|
||||
w_detection = settings.SCORING_WEIGHT_DETECTION_RULES
|
||||
w_d3fend = settings.SCORING_WEIGHT_D3FEND
|
||||
w_freshness = settings.SCORING_WEIGHT_FRESHNESS
|
||||
w_diversity = settings.SCORING_WEIGHT_PLATFORM_DIVERSITY
|
||||
w = get_scoring_weights(db)
|
||||
w_tests = w.tests
|
||||
w_detection = w.detection_rules
|
||||
w_d3fend = w.d3fend
|
||||
w_freshness = w.freshness
|
||||
w_diversity = w.platform_diversity
|
||||
|
||||
breakdown = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user