diff --git a/analyzer/management/commands/process_ml_feedback.py b/analyzer/management/commands/process_ml_feedback.py
index 36b3883..d35ec82 100644
--- a/analyzer/management/commands/process_ml_feedback.py
+++ b/analyzer/management/commands/process_ml_feedback.py
@@ -184,9 +184,7 @@ def show_feedback_statistics(self, options):
self.stdout.write(f" {field:<18} {counts}")
# Queries with feedback
- queries_with_feedback = (
- histories.values("query").distinct().count()
- )
+ queries_with_feedback = histories.values("query").distinct().count()
total_queries = Query.objects.count()
self.stdout.write("")
self.stdout.write(
@@ -225,9 +223,7 @@ def _get_queries_to_process(self, options):
if not options["force_all"]:
cutoff_date = timezone.now() - timedelta(days=options["days"])
recent = self._feedback_histories().filter(submitted_at__gte=cutoff_date)
- queryset = queryset.filter(
- id__in=recent.values("query")
- ).distinct()
+ queryset = queryset.filter(id__in=recent.values("query")).distinct()
# Get queries with sufficient feedback
queries_to_process = []
diff --git a/analyzer/management/commands/train_ml_model.py b/analyzer/management/commands/train_ml_model.py
index 9d0a22e..7538173 100644
--- a/analyzer/management/commands/train_ml_model.py
+++ b/analyzer/management/commands/train_ml_model.py
@@ -131,9 +131,7 @@ def handle(self, *args, **options):
else:
# Say why. Silently not deploying is how a bad model
# gets mistaken for a deploy that just did not happen.
- self.stdout.write(
- self.style.WARNING(f"Not deployed: {reason}")
- )
+ self.stdout.write(self.style.WARNING(f"Not deployed: {reason}"))
else:
raise CommandError(f"Training failed: {result.error_message}")
diff --git a/analyzer/migrations/0008_mlmodelartifact.py b/analyzer/migrations/0008_mlmodelartifact.py
index 50cf8ed..ade8fa9 100644
--- a/analyzer/migrations/0008_mlmodelartifact.py
+++ b/analyzer/migrations/0008_mlmodelartifact.py
@@ -1,26 +1,48 @@
# Generated by Django 4.2.30 on 2026-07-18 05:03
-from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
+from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('analyzer', '0007_queryanalysis_schema_insights'),
+ ("analyzer", "0007_queryanalysis_schema_insights"),
]
operations = [
migrations.CreateModel(
- name='MLModelArtifact',
+ name="MLModelArtifact",
fields=[
- ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('data', models.BinaryField(help_text='joblib-serialized model bytes')),
- ('byte_size', models.BigIntegerField(default=0)),
- ('checksum', models.CharField(blank=True, help_text='SHA256 of the stored bytes', max_length=64)),
- ('created_at', models.DateTimeField(default=django.utils.timezone.now)),
- ('model', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='artifact', to='analyzer.mlmodel')),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("data", models.BinaryField(help_text="joblib-serialized model bytes")),
+ ("byte_size", models.BigIntegerField(default=0)),
+ (
+ "checksum",
+ models.CharField(
+ blank=True,
+ help_text="SHA256 of the stored bytes",
+ max_length=64,
+ ),
+ ),
+ ("created_at", models.DateTimeField(default=django.utils.timezone.now)),
+ (
+ "model",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="artifact",
+ to="analyzer.mlmodel",
+ ),
+ ),
],
),
]
diff --git a/analyzer/ml/core/hybrid_grader.py b/analyzer/ml/core/hybrid_grader.py
index 72a9875..474c5c9 100644
--- a/analyzer/ml/core/hybrid_grader.py
+++ b/analyzer/ml/core/hybrid_grader.py
@@ -254,9 +254,7 @@ def _load_current_model(self) -> Optional[Any]:
model_data = retrieve_artifact(active_model)
if model_data is None:
- model_file_path = os.path.join(
- self.model_path, active_model.file_path
- )
+ model_file_path = os.path.join(self.model_path, active_model.file_path)
if not os.path.exists(model_file_path):
logger.error(f"Model file not found: {model_file_path}")
return None
diff --git a/analyzer/ml/core/training_gates.py b/analyzer/ml/core/training_gates.py
index f2df1a3..192809f 100644
--- a/analyzer/ml/core/training_gates.py
+++ b/analyzer/ml/core/training_gates.py
@@ -22,9 +22,7 @@ def real_training_sample_count() -> int:
"""Count TrainingData rows that came from real feedback, not the seed."""
from ...models import TrainingData
- return TrainingData.objects.exclude(
- validation_source=SYNTHETIC_SEED_SOURCE
- ).count()
+ return TrainingData.objects.exclude(validation_source=SYNTHETIC_SEED_SOURCE).count()
def real_feedback_gate():
diff --git a/analyzer/ml/core/training_pipeline.py b/analyzer/ml/core/training_pipeline.py
index 61e734f..e7b437c 100644
--- a/analyzer/ml/core/training_pipeline.py
+++ b/analyzer/ml/core/training_pipeline.py
@@ -264,9 +264,7 @@ def run_training_pipeline(self, force_retrain: bool = False) -> TrainingResult:
if ok:
self._deploy_model(model_version)
else:
- logger.warning(
- f"Model {model_version} not deployed: {reason}"
- )
+ logger.warning(f"Model {model_version} not deployed: {reason}")
training_time = (timezone.now() - start_time).total_seconds()
@@ -640,9 +638,7 @@ def cleanup_old_models(self, keep_versions: int = 5):
os.remove(local_file)
logger.info(f"Removed old model file: {local_file}")
except OSError as e:
- logger.warning(
- f"Could not remove model file {local_file}: {e}"
- )
+ logger.warning(f"Could not remove model file {local_file}: {e}")
# Remove database record (cascades to MLModelArtifact)
model.delete()
diff --git a/analyzer/ml/tests/test_alert_notifier.py b/analyzer/ml/tests/test_alert_notifier.py
index d78d49d..93de971 100644
--- a/analyzer/ml/tests/test_alert_notifier.py
+++ b/analyzer/ml/tests/test_alert_notifier.py
@@ -189,13 +189,14 @@ def setUp(self):
cache.clear()
def test_evaluation_sends_one_email_per_new_alert(self):
+ from django.utils import timezone as dj_timezone
+
from analyzer.ml.monitoring import alert_evaluator
from analyzer.ml.monitoring.retraining_system import (
RetrainingTrigger,
TriggerReason,
TriggerUrgency,
)
- from django.utils import timezone as dj_timezone
triggers = [
RetrainingTrigger(
diff --git a/analyzer/ml/tests/test_model_storage.py b/analyzer/ml/tests/test_model_storage.py
index 7285378..086ddac 100644
--- a/analyzer/ml/tests/test_model_storage.py
+++ b/analyzer/ml/tests/test_model_storage.py
@@ -97,9 +97,7 @@ def test_load_current_model_uses_db_artifact_without_local_file(self):
# came from the database.
with tempfile.TemporaryDirectory() as empty_dir:
grader.model_path = empty_dir
- self.assertFalse(
- os.path.exists(os.path.join(empty_dir, row.file_path))
- )
+ self.assertFalse(os.path.exists(os.path.join(empty_dir, row.file_path)))
loaded = grader._load_current_model()
self.assertIsNotNone(loaded)
diff --git a/analyzer/ml/tests/test_process_ml_feedback_command.py b/analyzer/ml/tests/test_process_ml_feedback_command.py
index 4a24672..985317f 100644
--- a/analyzer/ml/tests/test_process_ml_feedback_command.py
+++ b/analyzer/ml/tests/test_process_ml_feedback_command.py
@@ -145,9 +145,7 @@ def test_selection_threshold_defaults_to_the_collector_threshold(self):
output = self.run_cmd("--stats-only")
- self.assertIn(
- f"(>= {collector_min} feedback items): 0", output
- )
+ self.assertIn(f"(>= {collector_min} feedback items): 0", output)
def test_query_with_enough_feedback_is_found_and_processed(self):
self.add_detailed_feedback(FeedbackCollector().min_feedback_count)
diff --git a/analyzer/ml/tests/test_training_gates.py b/analyzer/ml/tests/test_training_gates.py
index 7865281..0252437 100644
--- a/analyzer/ml/tests/test_training_gates.py
+++ b/analyzer/ml/tests/test_training_gates.py
@@ -71,9 +71,7 @@ def test_real_count_ignores_seed_even_when_mixed(self):
self.assertTrue(training_gates.real_feedback_gate()[0])
-@override_settings(
- ML_MIN_REAL_FEEDBACK_SAMPLES=3, ML_MIN_TRAINING_SAMPLES=1
-)
+@override_settings(ML_MIN_REAL_FEEDBACK_SAMPLES=3, ML_MIN_TRAINING_SAMPLES=1)
class TrainingPipelineGateTests(TestCase):
"""The pipeline must refuse synthetic-only data even when the plain
sample-count gate (ML_MIN_TRAINING_SAMPLES) would pass."""
diff --git a/analyzer/test_analytics.py b/analyzer/test_analytics.py
index f372b85..0be6c82 100644
--- a/analyzer/test_analytics.py
+++ b/analyzer/test_analytics.py
@@ -133,8 +133,9 @@ def test_login_fires_user_login_on_the_next_page(self):
self.assertEqual(response.status_code, 302)
self.assertIn("_auth_user_id", self.client.session)
- self.assertEqual(self.rendered_event(self.client.get(response["Location"])),
- "user_login")
+ self.assertEqual(
+ self.rendered_event(self.client.get(response["Location"])), "user_login"
+ )
def test_logout_survives_the_session_flush(self):
"""logout() flushes the session, then the view writes the flag into
@@ -145,8 +146,9 @@ def test_logout_survives_the_session_flush(self):
self.assertEqual(response.status_code, 302)
self.assertNotIn("_auth_user_id", self.client.session)
- self.assertEqual(self.rendered_event(self.client.get(response["Location"])),
- "user_logout")
+ self.assertEqual(
+ self.rendered_event(self.client.get(response["Location"])), "user_logout"
+ )
def test_event_fires_exactly_once(self):
"""The pop has to mark the session dirty, or the flag survives and
diff --git a/analyzer/test_seo.py b/analyzer/test_seo.py
index a8fa439..6858b03 100644
--- a/analyzer/test_seo.py
+++ b/analyzer/test_seo.py
@@ -131,9 +131,7 @@ def test_home_page_has_exactly_one_self_referencing_canonical(self):
html = response.content.decode()
self.assertEqual(html.count('rel="canonical"'), 1)
- self.assertIn(
- '', html
- )
+ self.assertIn('', html)
def test_home_page_is_indexable_and_describes_itself(self):
response = self.client.get("/")
diff --git a/analyzer/urls.py b/analyzer/urls.py
index e7a481f..94d59df 100644
--- a/analyzer/urls.py
+++ b/analyzer/urls.py
@@ -9,7 +9,6 @@
# ML Dashboard views (separate module)
from .ml import dashboard_views
-from .views import ml_alert_views
# Import from modular views package
from .views import ( # Authentication views; Query grading views; Comparison views; Batch analysis views; History and feedback views; Upload views; Database introspection views; Async processing views; API views; Saved connection views
@@ -39,6 +38,7 @@
index,
login_view,
logout_view,
+ ml_alert_views,
password_change,
password_reset_confirm,
password_reset_request,
diff --git a/analyzer/views/ml_alert_views.py b/analyzer/views/ml_alert_views.py
index 8d916a4..ebcdaf7 100644
--- a/analyzer/views/ml_alert_views.py
+++ b/analyzer/views/ml_alert_views.py
@@ -20,8 +20,11 @@
from django.utils import timezone
from django.views.decorators.http import require_POST
-from analyzer.ml.monitoring.rollback import can_rollback
-from analyzer.ml.monitoring.rollback import RollbackError, perform_rollback
+from analyzer.ml.monitoring.rollback import (
+ RollbackError,
+ can_rollback,
+ perform_rollback,
+)
from analyzer.models import MLAlert, MLModel
logger = logging.getLogger(__name__)
diff --git a/querygrade/settings.py b/querygrade/settings.py
index 20c59f6..808cfaf 100644
--- a/querygrade/settings.py
+++ b/querygrade/settings.py
@@ -140,9 +140,7 @@
# ship another non-predictive model. This gate counts only rows whose
# validation_source is not the synthetic seed, so retraining waits for genuine
# user feedback to accumulate (see #92).
-ML_MIN_REAL_FEEDBACK_SAMPLES = int(
- os.environ.get("ML_MIN_REAL_FEEDBACK_SAMPLES", "25")
-)
+ML_MIN_REAL_FEEDBACK_SAMPLES = int(os.environ.get("ML_MIN_REAL_FEEDBACK_SAMPLES", "25"))
# Deploy quality gate, read by TrainingConfig. A model must clear BOTH the
# validation and the held-out test bar, and not show too large a gap between
@@ -152,9 +150,7 @@
ML_TEST_PERFORMANCE_THRESHOLD = float(
os.environ.get("ML_TEST_PERFORMANCE_THRESHOLD", "0.7")
)
-ML_MAX_VALIDATION_TEST_GAP = float(
- os.environ.get("ML_MAX_VALIDATION_TEST_GAP", "0.15")
-)
+ML_MAX_VALIDATION_TEST_GAP = float(os.environ.get("ML_MAX_VALIDATION_TEST_GAP", "0.15"))
# ML Feature Flags
# Default OFF: hybrid grading only fires for authenticated users, and the app