Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions analyzer/management/commands/process_ml_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 = []
Expand Down
4 changes: 1 addition & 3 deletions analyzer/management/commands/train_ml_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
40 changes: 31 additions & 9 deletions analyzer/migrations/0008_mlmodelartifact.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
4 changes: 1 addition & 3 deletions analyzer/ml/core/hybrid_grader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions analyzer/ml/core/training_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
8 changes: 2 additions & 6 deletions analyzer/ml/core/training_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion analyzer/ml/tests/test_alert_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 1 addition & 3 deletions analyzer/ml/tests/test_model_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions analyzer/ml/tests/test_process_ml_feedback_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 1 addition & 3 deletions analyzer/ml/tests/test_training_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
10 changes: 6 additions & 4 deletions analyzer/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 1 addition & 3 deletions analyzer/test_seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<link rel="canonical" href="https://querygrade.com/">', html
)
self.assertIn('<link rel="canonical" href="https://querygrade.com/">', html)

def test_home_page_is_indexable_and_describes_itself(self):
response = self.client.get("/")
Expand Down
2 changes: 1 addition & 1 deletion analyzer/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +38,7 @@
index,
login_view,
logout_view,
ml_alert_views,
password_change,
password_reset_confirm,
password_reset_request,
Expand Down
7 changes: 5 additions & 2 deletions analyzer/views/ml_alert_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
8 changes: 2 additions & 6 deletions querygrade/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down