data-advisor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data_advisor/__init__.py +38 -0
- data_advisor/__main__.py +9 -0
- data_advisor/advisor.py +436 -0
- data_advisor/cli.py +160 -0
- data_advisor/codegen.py +515 -0
- data_advisor/rules.py +380 -0
- data_advisor/scanner.py +655 -0
- data_advisor/search.py +679 -0
- data_advisor/visualizer.py +236 -0
- data_advisor-0.1.0.dist-info/METADATA +303 -0
- data_advisor-0.1.0.dist-info/RECORD +14 -0
- data_advisor-0.1.0.dist-info/WHEEL +5 -0
- data_advisor-0.1.0.dist-info/entry_points.txt +2 -0
- data_advisor-0.1.0.dist-info/top_level.txt +1 -0
data_advisor/rules.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rules.py - Heuristic decision engine for automated data diagnostics and modeling strategy.
|
|
3
|
+
|
|
4
|
+
Translates raw profiling metrics into high-impact, exam-winning recommendations
|
|
5
|
+
for cleaning, transformations, feature engineering, and baseline modeling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Recommendation:
|
|
16
|
+
"""Standardized recommendation container."""
|
|
17
|
+
category: str # 'cleaning', 'transformation', 'encoding', 'modeling', 'testing'
|
|
18
|
+
severity: str # 'CRITICAL', 'WARNING', 'RECOMMENDED', 'INFO'
|
|
19
|
+
column: Optional[str]
|
|
20
|
+
issue: str
|
|
21
|
+
action: str
|
|
22
|
+
code_hint: str
|
|
23
|
+
rationale: str
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RuleEngine:
|
|
30
|
+
"""
|
|
31
|
+
Evaluates scan results against industry-standard data science heuristics
|
|
32
|
+
and produces prioritized, actionable advice.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, scan_results: Dict[str, Any]) -> None:
|
|
36
|
+
self.scan = scan_results
|
|
37
|
+
self.overview = scan_results.get("overview", {})
|
|
38
|
+
self.missing = scan_results.get("missing_values", {})
|
|
39
|
+
self.dtypes = scan_results.get("data_types", {})
|
|
40
|
+
self.distributions = scan_results.get("distributions", {})
|
|
41
|
+
self.categoricals = scan_results.get("categoricals", {})
|
|
42
|
+
self.target = scan_results.get("target")
|
|
43
|
+
self.hypothesis_tests = scan_results.get("hypothesis_tests", [])
|
|
44
|
+
|
|
45
|
+
# -------------------------------------------------------------------------
|
|
46
|
+
# 1. Cleaning & Auditing Rules
|
|
47
|
+
# -------------------------------------------------------------------------
|
|
48
|
+
def evaluate_cleaning_rules(self) -> List[Recommendation]:
|
|
49
|
+
recs: List[Recommendation] = []
|
|
50
|
+
|
|
51
|
+
# Duplicate Rows
|
|
52
|
+
dup_count = self.overview.get("duplicate_rows", 0)
|
|
53
|
+
dup_pct = self.overview.get("duplicate_pct", 0.0)
|
|
54
|
+
if dup_count > 0:
|
|
55
|
+
severity = "CRITICAL" if dup_pct > 5.0 else "WARNING"
|
|
56
|
+
recs.append(Recommendation(
|
|
57
|
+
category="cleaning",
|
|
58
|
+
severity=severity,
|
|
59
|
+
column=None,
|
|
60
|
+
issue=f"Found {dup_count} duplicate rows ({dup_pct}% of dataset).",
|
|
61
|
+
action="Deduplicate dataset before analysis or modeling.",
|
|
62
|
+
code_hint="df = df.drop_duplicates().reset_index(drop=True)",
|
|
63
|
+
rationale="Duplicate rows cause artificial data leakage and bias model evaluations.",
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
# Constant Columns
|
|
67
|
+
for col in self.dtypes.get("constant_columns", []):
|
|
68
|
+
recs.append(Recommendation(
|
|
69
|
+
category="cleaning",
|
|
70
|
+
severity="WARNING",
|
|
71
|
+
column=col,
|
|
72
|
+
issue=f"Column '{col}' has zero variance (constant value).",
|
|
73
|
+
action=f"Drop column '{col}'.",
|
|
74
|
+
code_hint=f"df = df.drop(columns=['{col}'])",
|
|
75
|
+
rationale="Zero variance features provide zero predictive signal and cause multicollinearity.",
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
# High-Risk / High-Cardinality ID Columns
|
|
79
|
+
for col in self.dtypes.get("potential_ids", []):
|
|
80
|
+
recs.append(Recommendation(
|
|
81
|
+
category="cleaning",
|
|
82
|
+
severity="WARNING",
|
|
83
|
+
column=col,
|
|
84
|
+
issue=f"Column '{col}' appears to be a unique identifier / primary key.",
|
|
85
|
+
action=f"Exclude '{col}' from model training features.",
|
|
86
|
+
code_hint=f"id_col = '{col}'\ndf_features = df.drop(columns=['{col}'])",
|
|
87
|
+
rationale="Identifiers cause spurious correlation, extreme overfitting, and data leakage.",
|
|
88
|
+
))
|
|
89
|
+
|
|
90
|
+
# Missing Value Strategies
|
|
91
|
+
for col, info in self.missing.items():
|
|
92
|
+
pct = info.get("null_pct", 0.0)
|
|
93
|
+
count = info.get("null_count", 0)
|
|
94
|
+
is_num = info.get("is_numeric", False)
|
|
95
|
+
|
|
96
|
+
if pct == 0.0:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if pct >= 60.0:
|
|
100
|
+
recs.append(Recommendation(
|
|
101
|
+
category="cleaning",
|
|
102
|
+
severity="CRITICAL",
|
|
103
|
+
column=col,
|
|
104
|
+
issue=f"Column '{col}' has {pct}% missing values ({count} rows).",
|
|
105
|
+
action=f"Drop column '{col}' due to high sparsity (>60%).",
|
|
106
|
+
code_hint=f"df = df.drop(columns=['{col}'])",
|
|
107
|
+
rationale="Imputing >60% missing data injects massive noise and distorts distributions.",
|
|
108
|
+
))
|
|
109
|
+
elif pct < 5.0:
|
|
110
|
+
action_text = "Impute with median or drop missing rows." if is_num else "Impute with mode or drop missing rows."
|
|
111
|
+
code_text = (
|
|
112
|
+
f"df['{col}'] = df['{col}'].fillna(df['{col}'].median())"
|
|
113
|
+
if is_num
|
|
114
|
+
else f"df['{col}'] = df['{col}'].fillna(df['{col}'].mode()[0])"
|
|
115
|
+
)
|
|
116
|
+
recs.append(Recommendation(
|
|
117
|
+
category="cleaning",
|
|
118
|
+
severity="INFO",
|
|
119
|
+
column=col,
|
|
120
|
+
issue=f"Column '{col}' has minor missing values: {pct}% ({count} rows).",
|
|
121
|
+
action=action_text,
|
|
122
|
+
code_hint=code_text,
|
|
123
|
+
rationale="Low missingness (<5%) can be safely imputed with median/mode without altering distribution shape.",
|
|
124
|
+
))
|
|
125
|
+
else:
|
|
126
|
+
# 5% to 60% missing
|
|
127
|
+
if is_num:
|
|
128
|
+
dist = self.distributions.get(col, {})
|
|
129
|
+
skew = abs(dist.get("skewness", 0.0))
|
|
130
|
+
strategy = "median" if skew > 1.0 else "mean"
|
|
131
|
+
recs.append(Recommendation(
|
|
132
|
+
category="cleaning",
|
|
133
|
+
severity="WARNING",
|
|
134
|
+
column=col,
|
|
135
|
+
issue=f"Column '{col}' has moderate missing values: {pct}% ({count} rows).",
|
|
136
|
+
action=f"Impute with {strategy} (or use KNNImputer).",
|
|
137
|
+
code_hint=f"from sklearn.impute import SimpleImputer\nimputer = SimpleImputer(strategy='{strategy}')\ndf['{col}'] = imputer.fit_transform(df[['{col}']])",
|
|
138
|
+
rationale=f"Skewness is {dist.get('skewness', 0.0):.2f}. Median is robust to skew; mean is suitable for symmetric data.",
|
|
139
|
+
))
|
|
140
|
+
else:
|
|
141
|
+
recs.append(Recommendation(
|
|
142
|
+
category="cleaning",
|
|
143
|
+
severity="WARNING",
|
|
144
|
+
column=col,
|
|
145
|
+
issue=f"Categorical column '{col}' has {pct}% missing values.",
|
|
146
|
+
action="Impute with mode or treat missingness as an explicit category ('Unknown').",
|
|
147
|
+
code_hint=f"df['{col}'] = df['{col}'].fillna('Missing')",
|
|
148
|
+
rationale="Preserving missingness as 'Missing' allows models to capture potential informative missingness bias.",
|
|
149
|
+
))
|
|
150
|
+
|
|
151
|
+
# Date string mismatches
|
|
152
|
+
for col in self.dtypes.get("potential_dates", []):
|
|
153
|
+
recs.append(Recommendation(
|
|
154
|
+
category="cleaning",
|
|
155
|
+
severity="RECOMMENDED",
|
|
156
|
+
column=col,
|
|
157
|
+
issue=f"Column '{col}' contains date-like strings but is stored as object/string.",
|
|
158
|
+
action=f"Convert '{col}' to datetime and extract temporal components.",
|
|
159
|
+
code_hint=(
|
|
160
|
+
f"df['{col}'] = pd.to_datetime(df['{col}'], errors='coerce')\n"
|
|
161
|
+
f"df['{col}_year'] = df['{col}'].dt.year\n"
|
|
162
|
+
f"df['{col}_month'] = df['{col}'].dt.month\n"
|
|
163
|
+
f"df['{col}_dayofweek'] = df['{col}'].dt.dayofweek"
|
|
164
|
+
),
|
|
165
|
+
rationale="Machine learning models cannot process raw date strings; cyclic/temporal features extract high predictive value.",
|
|
166
|
+
))
|
|
167
|
+
|
|
168
|
+
return recs
|
|
169
|
+
|
|
170
|
+
# -------------------------------------------------------------------------
|
|
171
|
+
# 2. Distribution & Outlier Transformation Rules
|
|
172
|
+
# -------------------------------------------------------------------------
|
|
173
|
+
def evaluate_distribution_rules(self) -> List[Recommendation]:
|
|
174
|
+
recs: List[Recommendation] = []
|
|
175
|
+
|
|
176
|
+
for col, dist in self.distributions.items():
|
|
177
|
+
skew = dist.get("skewness", 0.0)
|
|
178
|
+
kurt = dist.get("kurtosis", 0.0)
|
|
179
|
+
is_non_neg = dist.get("is_non_negative", True)
|
|
180
|
+
iqr_pct = dist.get("iqr_outlier_pct", 0.0)
|
|
181
|
+
iqr_cnt = dist.get("iqr_outliers", 0)
|
|
182
|
+
lower_fence = dist.get("lower_fence", 0.0)
|
|
183
|
+
upper_fence = dist.get("upper_fence", 0.0)
|
|
184
|
+
|
|
185
|
+
# Heavy Positive Skewness
|
|
186
|
+
if skew > 1.2:
|
|
187
|
+
if is_non_neg:
|
|
188
|
+
recs.append(Recommendation(
|
|
189
|
+
category="transformation",
|
|
190
|
+
severity="RECOMMENDED",
|
|
191
|
+
column=col,
|
|
192
|
+
issue=f"Column '{col}' is heavily right-skewed (skewness = {skew:.2f}).",
|
|
193
|
+
action="Apply log1p transformation to normalize distribution.",
|
|
194
|
+
code_hint=f"df['{col}_log'] = np.log1p(df['{col}'])",
|
|
195
|
+
rationale="Log transformation stabilizes variance and minimizes leverage of extreme positive values in linear/distance models.",
|
|
196
|
+
))
|
|
197
|
+
else:
|
|
198
|
+
recs.append(Recommendation(
|
|
199
|
+
category="transformation",
|
|
200
|
+
severity="RECOMMENDED",
|
|
201
|
+
column=col,
|
|
202
|
+
issue=f"Column '{col}' is right-skewed (skewness = {skew:.2f}) and contains negative values.",
|
|
203
|
+
action="Apply Yeo-Johnson power transformation.",
|
|
204
|
+
code_hint=(
|
|
205
|
+
f"from sklearn.preprocessing import PowerTransformer\n"
|
|
206
|
+
f"pt = PowerTransformer(method='yeo-johnson')\n"
|
|
207
|
+
f"df['{col}_transformed'] = pt.fit_transform(df[['{col}']])"
|
|
208
|
+
),
|
|
209
|
+
rationale="Yeo-Johnson supports zero and negative values while normalizing non-Gaussian distributions.",
|
|
210
|
+
))
|
|
211
|
+
elif skew < -1.2:
|
|
212
|
+
recs.append(Recommendation(
|
|
213
|
+
category="transformation",
|
|
214
|
+
severity="RECOMMENDED",
|
|
215
|
+
column=col,
|
|
216
|
+
issue=f"Column '{col}' is heavily left-skewed (skewness = {skew:.2f}).",
|
|
217
|
+
action="Apply Yeo-Johnson transformation or upper/lower bound clipping.",
|
|
218
|
+
code_hint=(
|
|
219
|
+
f"from sklearn.preprocessing import PowerTransformer\n"
|
|
220
|
+
f"pt = PowerTransformer(method='yeo-johnson')\n"
|
|
221
|
+
f"df['{col}_transformed'] = pt.fit_transform(df[['{col}']])"
|
|
222
|
+
),
|
|
223
|
+
rationale="Left-skewed distributions violate normality assumptions in parametric tests and models.",
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
# Severe Outliers
|
|
227
|
+
if iqr_pct > 3.0:
|
|
228
|
+
recs.append(Recommendation(
|
|
229
|
+
category="transformation",
|
|
230
|
+
severity="WARNING",
|
|
231
|
+
column=col,
|
|
232
|
+
issue=f"Column '{col}' has {iqr_cnt} IQR outliers ({iqr_pct}%).",
|
|
233
|
+
action=f"Clip values to IQR fences [{lower_fence:.2f}, {upper_fence:.2f}] or scale with RobustScaler.",
|
|
234
|
+
code_hint=(
|
|
235
|
+
f"df['{col}_clipped'] = df['{col}'].clip(lower={lower_fence}, upper={upper_fence})"
|
|
236
|
+
),
|
|
237
|
+
rationale="Extreme outliers distort mean, inflate standard error, and destabilize gradient-based estimators.",
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
return recs
|
|
241
|
+
|
|
242
|
+
# -------------------------------------------------------------------------
|
|
243
|
+
# 3. Categorical Processing Rules
|
|
244
|
+
# -------------------------------------------------------------------------
|
|
245
|
+
def evaluate_categorical_rules(self) -> List[Recommendation]:
|
|
246
|
+
recs: List[Recommendation] = []
|
|
247
|
+
|
|
248
|
+
for col, cat in self.categoricals.items():
|
|
249
|
+
cardinality = cat.get("cardinality", 0)
|
|
250
|
+
|
|
251
|
+
if cardinality == 2:
|
|
252
|
+
samples = cat.get("sample_values", [])
|
|
253
|
+
recs.append(Recommendation(
|
|
254
|
+
category="encoding",
|
|
255
|
+
severity="RECOMMENDED",
|
|
256
|
+
column=col,
|
|
257
|
+
issue=f"Binary categorical column '{col}' (values: {samples}).",
|
|
258
|
+
action="Map directly to binary 0/1 integers.",
|
|
259
|
+
code_hint=(
|
|
260
|
+
f"mapping = {{{repr(samples[0])}: 0, {repr(samples[1])}: 1}}\n"
|
|
261
|
+
f"df['{col}_encoded'] = df['{col}'].map(mapping)"
|
|
262
|
+
),
|
|
263
|
+
rationale="Binary integer mapping avoids redundant dimensions while maintaining zero information loss.",
|
|
264
|
+
))
|
|
265
|
+
elif 3 <= cardinality <= 10:
|
|
266
|
+
recs.append(Recommendation(
|
|
267
|
+
category="encoding",
|
|
268
|
+
severity="RECOMMENDED",
|
|
269
|
+
column=col,
|
|
270
|
+
issue=f"Low-cardinality categorical column '{col}' ({cardinality} levels).",
|
|
271
|
+
action="Use One-Hot Encoding with drop_first=True.",
|
|
272
|
+
code_hint=f"df = pd.get_dummies(df, columns=['{col}'], drop_first=True, dtype=int)",
|
|
273
|
+
rationale="One-hot encoding with drop_first prevents the dummy variable trap (multicollinearity).",
|
|
274
|
+
))
|
|
275
|
+
elif cardinality > 10:
|
|
276
|
+
recs.append(Recommendation(
|
|
277
|
+
category="encoding",
|
|
278
|
+
severity="WARNING",
|
|
279
|
+
column=col,
|
|
280
|
+
issue=f"High-cardinality categorical column '{col}' ({cardinality} unique levels).",
|
|
281
|
+
action="Apply Frequency Encoding, Target Encoding, or collapse rare categories into 'Other'.",
|
|
282
|
+
code_hint=(
|
|
283
|
+
f"# Frequency Encoding\n"
|
|
284
|
+
f"freq_map = df['{col}'].value_counts(normalize=True)\n"
|
|
285
|
+
f"df['{col}_freq'] = df['{col}'].map(freq_map)\n\n"
|
|
286
|
+
f"# Or Scikit-Learn Target Encoding\n"
|
|
287
|
+
f"# from sklearn.preprocessing import TargetEncoder\n"
|
|
288
|
+
f"# te = TargetEncoder(smooth='auto')\n"
|
|
289
|
+
f"# df['{col}_te'] = te.fit_transform(df[['{col}']], y)"
|
|
290
|
+
),
|
|
291
|
+
rationale="One-hot encoding high-cardinality columns explodes feature space, causing the curse of dimensionality.",
|
|
292
|
+
))
|
|
293
|
+
|
|
294
|
+
return recs
|
|
295
|
+
|
|
296
|
+
# -------------------------------------------------------------------------
|
|
297
|
+
# 4. Target Variable & Baseline Modeling Strategy
|
|
298
|
+
# -------------------------------------------------------------------------
|
|
299
|
+
def evaluate_modeling_rules(self) -> List[Recommendation]:
|
|
300
|
+
recs: List[Recommendation] = []
|
|
301
|
+
if not self.target:
|
|
302
|
+
return recs
|
|
303
|
+
|
|
304
|
+
target_col = self.target.get("target_column")
|
|
305
|
+
prob_type = self.target.get("problem_type")
|
|
306
|
+
|
|
307
|
+
if prob_type == "regression":
|
|
308
|
+
recs.append(Recommendation(
|
|
309
|
+
category="modeling",
|
|
310
|
+
severity="INFO",
|
|
311
|
+
column=target_col,
|
|
312
|
+
issue=f"Target '{target_col}' is continuous ({self.target.get('n_samples')} valid samples).",
|
|
313
|
+
action="Deploy Regression Pipeline (Baseline Linear -> Ridge -> RandomForestRegressor).",
|
|
314
|
+
code_hint=(
|
|
315
|
+
"from sklearn.linear_model import LinearRegression, Ridge\n"
|
|
316
|
+
"from sklearn.ensemble import RandomForestRegressor\n"
|
|
317
|
+
"from sklearn.metrics import root_mean_squared_error, mean_absolute_error, r2_score\n\n"
|
|
318
|
+
"models = {\n"
|
|
319
|
+
" 'LinearRegression': LinearRegression(),\n"
|
|
320
|
+
" 'Ridge': Ridge(alpha=1.0),\n"
|
|
321
|
+
" 'RandomForest': RandomForestRegressor(n_estimators=100, random_state=42)\n"
|
|
322
|
+
"}"
|
|
323
|
+
),
|
|
324
|
+
rationale="Provides both interpretable parametric baselines (OLS/Ridge) and robust non-linear tree ensembles.",
|
|
325
|
+
))
|
|
326
|
+
elif prob_type == "classification":
|
|
327
|
+
is_imbalanced = self.target.get("is_imbalanced", False)
|
|
328
|
+
imbalance_ratio = self.target.get("imbalance_ratio", 1.0)
|
|
329
|
+
minority_pct = self.target.get("minority_percentage", 50.0)
|
|
330
|
+
|
|
331
|
+
severity = "CRITICAL" if is_imbalanced else "INFO"
|
|
332
|
+
issue_msg = (
|
|
333
|
+
f"Severe target imbalance detected! Minority class: {minority_pct}% (Ratio 1:{imbalance_ratio:.1f})."
|
|
334
|
+
if is_imbalanced
|
|
335
|
+
else f"Balanced classification target ({self.target.get('n_classes')} classes)."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
action_msg = (
|
|
339
|
+
"Use StratifiedKFold, class_weight='balanced', and optimize for PR-AUC/F1-Score."
|
|
340
|
+
if is_imbalanced
|
|
341
|
+
else "Deploy Classification Pipeline (LogisticRegression -> RandomForestClassifier)."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
code_hint = (
|
|
345
|
+
"from sklearn.linear_model import LogisticRegression\n"
|
|
346
|
+
"from sklearn.ensemble import RandomForestClassifier\n"
|
|
347
|
+
"from sklearn.metrics import classification_report, roc_auc_score\n\n"
|
|
348
|
+
"models = {\n"
|
|
349
|
+
f" 'LogisticRegression': LogisticRegression(class_weight={'\"balanced\"' if is_imbalanced else 'None'}, max_iter=1000),\n"
|
|
350
|
+
f" 'RandomForest': RandomForestClassifier(n_estimators=100, class_weight={'\"balanced\"' if is_imbalanced else 'None'}, random_state=42)\n"
|
|
351
|
+
"}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
recs.append(Recommendation(
|
|
355
|
+
category="modeling",
|
|
356
|
+
severity=severity,
|
|
357
|
+
column=target_col,
|
|
358
|
+
issue=issue_msg,
|
|
359
|
+
action=action_msg,
|
|
360
|
+
code_hint=code_hint,
|
|
361
|
+
rationale="Accuracy is deceptive under class imbalance. Balanced loss penalties and precision-recall metrics prevent majority-class collapse.",
|
|
362
|
+
))
|
|
363
|
+
|
|
364
|
+
return recs
|
|
365
|
+
|
|
366
|
+
# -------------------------------------------------------------------------
|
|
367
|
+
# 5. Master Recommendation Engine
|
|
368
|
+
# -------------------------------------------------------------------------
|
|
369
|
+
def generate_all_recommendations(self) -> List[Recommendation]:
|
|
370
|
+
"""Aggregate all rule checks in order of execution priority."""
|
|
371
|
+
recs: List[Recommendation] = []
|
|
372
|
+
recs.extend(self.evaluate_cleaning_rules())
|
|
373
|
+
recs.extend(self.evaluate_distribution_rules())
|
|
374
|
+
recs.extend(self.evaluate_categorical_rules())
|
|
375
|
+
recs.extend(self.evaluate_modeling_rules())
|
|
376
|
+
|
|
377
|
+
# Sort recommendations by severity priority: CRITICAL -> WARNING -> RECOMMENDED -> INFO
|
|
378
|
+
priority_map = {"CRITICAL": 0, "WARNING": 1, "RECOMMENDED": 2, "INFO": 3}
|
|
379
|
+
recs.sort(key=lambda r: priority_map.get(r.severity, 4))
|
|
380
|
+
return recs
|