feat(learnings): added learnings business logic

This commit is contained in:
ITQ
2026-02-23 10:57:27 +03:00
parent f3350ff81e
commit e9e64a7ce5
8 changed files with 719 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LearningsConfig(AppConfig):
name = "apps.learnings"
label = "learnings"
@@ -0,0 +1,64 @@
# Generated by Django 5.2.11 on 2026-02-22 16:19
import django.contrib.postgres.indexes
import django.contrib.postgres.search
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('experiments', '0002_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Learning',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('hypothesis', models.TextField(verbose_name='hypothesis')),
('findings', models.TextField(verbose_name='findings')),
('tags', models.JSONField(blank=True, default=list, verbose_name='tags')),
('context_summary', models.TextField(blank=True, verbose_name='context summary')),
('search_vector', django.contrib.postgres.search.SearchVectorField(blank=True, null=True, verbose_name='search vector')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_learnings', to=settings.AUTH_USER_MODEL, verbose_name='created by')),
('experiment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='learning', to='experiments.experiment', verbose_name='experiment')),
],
options={
'verbose_name': 'learning',
'verbose_name_plural': 'learnings',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='LearningEdit',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('changes', models.JSONField(default=dict, verbose_name='changes')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('edited_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='learning_edits', to=settings.AUTH_USER_MODEL, verbose_name='edited by')),
('learning', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edits', to='learnings.learning', verbose_name='learning')),
],
options={
'verbose_name': 'learning edit',
'verbose_name_plural': 'learning edits',
'ordering': ['-created_at'],
},
),
migrations.AddIndex(
model_name='learning',
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='idx_learning_search'),
),
migrations.AddIndex(
model_name='learning',
index=models.Index(fields=['experiment'], name='idx_learning_experiment'),
),
]
+102
View File
@@ -0,0 +1,102 @@
from typing import override
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.core.models import BaseModel
class Learning(BaseModel):
experiment = models.OneToOneField(
"experiments.Experiment",
on_delete=models.CASCADE,
related_name="learning",
verbose_name=_("experiment"),
)
hypothesis = models.TextField(
verbose_name=_("hypothesis"),
)
findings = models.TextField(
verbose_name=_("findings"),
)
tags = models.JSONField(
default=list,
blank=True,
verbose_name=_("tags"),
)
context_summary = models.TextField(
blank=True,
verbose_name=_("context summary"),
)
search_vector = SearchVectorField(
null=True,
blank=True,
verbose_name=_("search vector"),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_learnings",
verbose_name=_("created by"),
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created at"),
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_("updated at"),
)
class Meta:
verbose_name = _("learning")
verbose_name_plural = _("learnings")
ordering = ["-created_at"]
indexes = [
GinIndex(fields=["search_vector"], name="idx_learning_search"),
models.Index(fields=["experiment"], name="idx_learning_experiment"),
]
@override
def __str__(self) -> str:
return f"Learning: {self.experiment.name}"
class LearningEdit(BaseModel):
learning = models.ForeignKey(
Learning,
on_delete=models.CASCADE,
related_name="edits",
verbose_name=_("learning"),
)
edited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="learning_edits",
verbose_name=_("edited by"),
)
changes = models.JSONField(
default=dict,
verbose_name=_("changes"),
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created at"),
)
class Meta:
verbose_name = _("learning edit")
verbose_name_plural = _("learning edits")
ordering = ["-created_at"]
@override
def __str__(self) -> str:
editor = self.edited_by.username if self.edited_by else "system"
return f"Edit by {editor} on {self.learning}"
+268
View File
@@ -0,0 +1,268 @@
import logging
from typing import Any
from uuid import UUID
from django.db import connection, transaction
from django.db.models import Q, QuerySet
from apps.experiments.models import Experiment, ExperimentOutcome, ExperimentStatus
from apps.guardrails.models import GuardrailTrigger
from apps.learnings.models import Learning, LearningEdit
from apps.metrics.models import ExperimentMetric
from apps.users.models import User
logger = logging.getLogger("lotty")
def _is_postgres() -> bool:
return connection.vendor == "postgresql"
@transaction.atomic
def learning_create(
*,
experiment: Experiment,
hypothesis: str,
findings: str,
tags: list[str] | None = None,
context_summary: str = "",
user: User | None = None,
) -> Learning:
learning = Learning(
experiment=experiment,
hypothesis=hypothesis,
findings=findings,
tags=tags or [],
context_summary=context_summary,
created_by=user,
)
learning.save()
_update_search_vector(learning)
return learning
def learning_update(
*,
learning: Learning,
user: User | None = None,
**fields: Any,
) -> Learning:
allowed = {"hypothesis", "findings", "tags", "context_summary"}
changes: dict[str, Any] = {}
for key in fields:
if key not in allowed:
raise ValueError(f"Field '{key}' cannot be updated.")
for key, value in fields.items():
if value is not None:
old_value = getattr(learning, key)
if old_value != value:
changes[key] = {"old": old_value, "new": value}
setattr(learning, key, value)
if changes:
learning.save()
_update_search_vector(learning)
LearningEdit.objects.create(
learning=learning,
edited_by=user,
changes=changes,
)
return learning
def learning_delete(*, learning: Learning) -> None:
learning.delete()
def learning_get(learning_id: UUID) -> Learning | None:
try:
return Learning.objects.select_related(
"experiment__flag",
"experiment__owner",
"created_by",
).get(pk=learning_id)
except Learning.DoesNotExist:
return None
def learning_list(
*,
flag_id: UUID | None = None,
owner_id: UUID | None = None,
outcome: str | None = None,
tag: str | None = None,
search: str | None = None,
) -> QuerySet[Learning]:
qs = Learning.objects.select_related(
"experiment__flag",
"experiment__owner",
"created_by",
).all()
if flag_id is not None:
qs = qs.filter(experiment__flag_id=flag_id)
if owner_id is not None:
qs = qs.filter(experiment__owner_id=owner_id)
if outcome is not None:
qs = qs.filter(experiment__outcome__outcome=outcome)
if tag is not None:
if _is_postgres():
qs = qs.filter(tags__contains=[tag])
else:
qs = qs.filter(tags__icontains=tag)
if search is not None:
if _is_postgres():
from django.contrib.postgres.search import SearchQuery, SearchRank
query = SearchQuery(search, config="english")
qs = qs.filter(search_vector=query).annotate(
rank=SearchRank("search_vector", query)
).order_by("-rank")
else:
qs = qs.filter(
Q(hypothesis__icontains=search)
| Q(findings__icontains=search)
| Q(context_summary__icontains=search)
)
return qs
def learning_edit_list(learning_id: UUID) -> QuerySet[LearningEdit]:
return LearningEdit.objects.select_related(
"edited_by",
).filter(learning_id=learning_id)
def find_similar_learnings(
*,
experiment_id: UUID,
limit: int = 5,
) -> list[dict[str, Any]]:
try:
experiment = Experiment.objects.select_related("flag").get(pk=experiment_id)
except Experiment.DoesNotExist:
return []
experiment_flag_id = experiment.flag_id
experiment_metric_ids = set(
ExperimentMetric.objects.filter(
experiment_id=experiment_id
).values_list("metric_id", flat=True)
)
experiment_tags = set()
try:
learning = Learning.objects.get(experiment_id=experiment_id)
experiment_tags = set(learning.tags or [])
except Learning.DoesNotExist:
pass
had_guardrail_triggers = GuardrailTrigger.objects.filter(
experiment_id=experiment_id
).exists()
candidates = Learning.objects.select_related(
"experiment__flag",
"experiment__owner",
"experiment__outcome",
).exclude(
experiment_id=experiment_id,
).order_by("-created_at")[:100]
scored: list[tuple[float, Learning]] = []
for candidate in candidates:
score = _jaccard_score(
candidate=candidate,
experiment_flag_id=experiment_flag_id,
experiment_metric_ids=experiment_metric_ids,
experiment_tags=experiment_tags,
had_guardrail_triggers=had_guardrail_triggers,
)
if score > 0:
scored.append((score, candidate))
scored.sort(key=lambda x: x[0], reverse=True)
results: list[dict[str, Any]] = []
for score, candidate in scored[:limit]:
outcome_data = None
if hasattr(candidate.experiment, "outcome"):
try:
exp_outcome = candidate.experiment.outcome
outcome_data = {
"outcome": exp_outcome.outcome,
"rationale": exp_outcome.rationale,
"winning_variant": str(exp_outcome.winning_variant_id) if exp_outcome.winning_variant_id else None,
}
except ExperimentOutcome.DoesNotExist:
pass
trigger_count = GuardrailTrigger.objects.filter(
experiment_id=candidate.experiment_id
).count()
results.append({
"learning_id": str(candidate.pk),
"experiment_id": str(candidate.experiment_id),
"experiment_name": candidate.experiment.name,
"flag_key": candidate.experiment.flag.key,
"hypothesis": candidate.hypothesis,
"findings": candidate.findings,
"tags": candidate.tags,
"outcome": outcome_data,
"had_guardrail_triggers": trigger_count > 0,
"guardrail_trigger_count": trigger_count,
"similarity_score": round(score, 3),
})
return results
def _jaccard_score(
*,
candidate: Learning,
experiment_flag_id: UUID,
experiment_metric_ids: set,
experiment_tags: set,
had_guardrail_triggers: bool,
) -> float:
score = 0.0
if candidate.experiment.flag_id == experiment_flag_id:
score += 0.4
candidate_tags = set(candidate.tags or [])
if experiment_tags and candidate_tags:
intersection = experiment_tags & candidate_tags
union = experiment_tags | candidate_tags
if union:
score += 0.3 * (len(intersection) / len(union))
candidate_metric_ids = set(
ExperimentMetric.objects.filter(
experiment_id=candidate.experiment_id,
).values_list("metric_id", flat=True)
)
if experiment_metric_ids and candidate_metric_ids:
m_intersection = experiment_metric_ids & candidate_metric_ids
m_union = experiment_metric_ids | candidate_metric_ids
if m_union:
score += 0.2 * (len(m_intersection) / len(m_union))
candidate_had_triggers = GuardrailTrigger.objects.filter(
experiment_id=candidate.experiment_id
).exists()
if had_guardrail_triggers == candidate_had_triggers:
score += 0.1
return score
def _update_search_vector(learning: Learning) -> None:
if not _is_postgres():
return
from django.contrib.postgres.search import SearchVector
Learning.objects.filter(pk=learning.pk).update(
search_vector=(
SearchVector("hypothesis", weight="A", config="english")
+ SearchVector("findings", weight="B", config="english")
+ SearchVector("context_summary", weight="C", config="english")
)
)
@@ -0,0 +1,279 @@
from typing import override
from django.test import TestCase
from apps.experiments.models import ExperimentOutcome, ExperimentStatus, OutcomeType
from apps.experiments.services import experiment_complete
from apps.experiments.tests.helpers import add_two_variants, make_experiment, make_flag
from apps.guardrails.models import Guardrail, GuardrailTrigger
from apps.learnings.models import Learning, LearningEdit
from apps.learnings.services import (
find_similar_learnings,
learning_create,
learning_delete,
learning_edit_list,
learning_get,
learning_list,
learning_update,
)
from apps.metrics.models import ExperimentMetric, MetricDefinition, MetricType
from apps.reviews.tests.helpers import make_admin
from apps.users.tests.helpers import make_user
class LearningCRUDTest(TestCase):
@override
def setUp(self) -> None:
self.user = make_admin("_lcrud")
self.exp = make_experiment(suffix="_lcrud", owner=self.user)
def test_create_learning(self) -> None:
l = learning_create(
experiment=self.exp,
hypothesis="Changing button color increases CTR",
findings="Blue variant showed +5% CTR improvement",
tags=["ui", "ctr", "button"],
context_summary="Checkout page button color test",
user=self.user,
)
self.assertEqual(l.experiment, self.exp)
self.assertEqual(l.hypothesis, "Changing button color increases CTR")
self.assertEqual(l.findings, "Blue variant showed +5% CTR improvement")
self.assertEqual(l.tags, ["ui", "ctr", "button"])
self.assertEqual(l.created_by, self.user)
def test_create_learning_minimal(self) -> None:
l = learning_create(
experiment=self.exp,
hypothesis="Test hypothesis",
findings="Test findings",
)
self.assertEqual(l.tags, [])
self.assertEqual(l.context_summary, "")
self.assertIsNone(l.created_by)
def test_create_duplicate_learning_fails(self) -> None:
learning_create(
experiment=self.exp,
hypothesis="First",
findings="First findings",
)
with self.assertRaises(Exception):
learning_create(
experiment=self.exp,
hypothesis="Second",
findings="Second findings",
)
def test_get_learning(self) -> None:
l = learning_create(
experiment=self.exp,
hypothesis="Test",
findings="Test findings",
)
fetched = learning_get(l.pk)
self.assertIsNotNone(fetched)
self.assertEqual(fetched.pk, l.pk)
def test_get_nonexistent_learning(self) -> None:
import uuid
result = learning_get(uuid.uuid4())
self.assertIsNone(result)
def test_delete_learning(self) -> None:
l = learning_create(
experiment=self.exp,
hypothesis="Test",
findings="Test findings",
)
learning_delete(learning=l)
self.assertIsNone(learning_get(l.pk))
class LearningUpdateTest(TestCase):
@override
def setUp(self) -> None:
self.user = make_admin("_lupd")
self.exp = make_experiment(suffix="_lupd", owner=self.user)
self.learning = learning_create(
experiment=self.exp,
hypothesis="Original hypothesis",
findings="Original findings",
tags=["original"],
user=self.user,
)
def test_update_hypothesis(self) -> None:
editor = make_user(
username="editor_lupd",
email="editor_lupd@lotty.local",
)
l = learning_update(
learning=self.learning,
user=editor,
hypothesis="Updated hypothesis",
)
self.assertEqual(l.hypothesis, "Updated hypothesis")
def test_update_creates_audit_trail(self) -> None:
editor = make_user(
username="editor2_lupd",
email="editor2_lupd@lotty.local",
)
learning_update(
learning=self.learning,
user=editor,
findings="New findings",
)
edits = learning_edit_list(self.learning.pk)
self.assertEqual(edits.count(), 1)
edit = edits.first()
self.assertEqual(edit.edited_by, editor)
self.assertIn("findings", edit.changes)
self.assertEqual(edit.changes["findings"]["old"], "Original findings")
self.assertEqual(edit.changes["findings"]["new"], "New findings")
def test_update_no_change_no_audit(self) -> None:
learning_update(
learning=self.learning,
user=self.user,
hypothesis="Original hypothesis",
)
edits = learning_edit_list(self.learning.pk)
self.assertEqual(edits.count(), 0)
def test_update_disallowed_field(self) -> None:
with self.assertRaises(ValueError):
learning_update(
learning=self.learning,
user=self.user,
experiment="other",
)
def test_update_multiple_fields(self) -> None:
learning_update(
learning=self.learning,
user=self.user,
hypothesis="New hyp",
findings="New find",
tags=["new", "tags"],
)
edits = learning_edit_list(self.learning.pk)
self.assertEqual(edits.count(), 1)
edit = edits.first()
self.assertIn("hypothesis", edit.changes)
self.assertIn("findings", edit.changes)
self.assertIn("tags", edit.changes)
class LearningListTest(TestCase):
@override
def setUp(self) -> None:
self.user = make_admin("_llist")
self.flag = make_flag(suffix="_llist")
self.exp = make_experiment(
flag=self.flag,
owner=self.user,
suffix="_llist",
)
self.learning = learning_create(
experiment=self.exp,
hypothesis="Test hypothesis about button color",
findings="Blue variant performed better",
tags=["ui", "button"],
)
def test_list_all(self) -> None:
learnings = learning_list()
self.assertGreaterEqual(learnings.count(), 1)
def test_filter_by_flag(self) -> None:
learnings = learning_list(flag_id=self.flag.pk)
self.assertEqual(learnings.count(), 1)
def test_filter_by_tag(self) -> None:
learnings = learning_list(tag="ui")
self.assertTrue(all("ui" in l.tags for l in learnings))
def test_filter_by_tag_no_match(self) -> None:
learnings = learning_list(tag="nonexistent")
self.assertEqual(learnings.count(), 0)
def test_filter_by_owner(self) -> None:
learnings = learning_list(owner_id=self.user.pk)
self.assertEqual(learnings.count(), 1)
def test_fulltext_search(self) -> None:
learnings = learning_list(search="button color")
self.assertGreaterEqual(learnings.count(), 1)
class SimilarLearningsTest(TestCase):
@override
def setUp(self) -> None:
self.user = make_admin("_sim")
self.flag = make_flag(suffix="_sim1")
self.exp1 = make_experiment(flag=self.flag, owner=self.user, suffix="_sim1")
self.learning1 = learning_create(
experiment=self.exp1,
hypothesis="Test button color",
findings="Blue won",
tags=["ui", "button", "checkout"],
)
flag2 = make_flag(suffix="_sim2")
self.exp2 = make_experiment(flag=flag2, owner=self.user, suffix="_sim2")
self.learning2 = learning_create(
experiment=self.exp2,
hypothesis="Test font size",
findings="Larger font won",
tags=["ui", "font"],
)
self.exp3 = make_experiment(
flag=self.flag,
owner=self.user,
suffix="_sim3",
name="SameFlag Exp",
)
self.learning3 = learning_create(
experiment=self.exp3,
hypothesis="Another button test",
findings="Red won",
tags=["ui", "button", "checkout"],
)
def test_find_similar_same_flag(self) -> None:
results = find_similar_learnings(experiment_id=self.exp1.pk)
self.assertGreaterEqual(len(results), 1)
flag_keys = [r["flag_key"] for r in results]
self.assertIn(self.flag.key, flag_keys)
def test_find_similar_tag_overlap(self) -> None:
results = find_similar_learnings(experiment_id=self.exp1.pk)
self.assertTrue(len(results) > 0)
top_result = results[0]
self.assertGreater(top_result["similarity_score"], 0)
def test_find_similar_excludes_self(self) -> None:
results = find_similar_learnings(experiment_id=self.exp1.pk)
result_exp_ids = [r["experiment_id"] for r in results]
self.assertNotIn(str(self.exp1.pk), result_exp_ids)
def test_find_similar_nonexistent_experiment(self) -> None:
import uuid
results = find_similar_learnings(experiment_id=uuid.uuid4())
self.assertEqual(results, [])
def test_find_similar_respects_limit(self) -> None:
results = find_similar_learnings(experiment_id=self.exp1.pk, limit=1)
self.assertLessEqual(len(results), 1)
def test_similar_highest_score_is_same_flag_and_tags(self) -> None:
results = find_similar_learnings(experiment_id=self.exp1.pk)
if len(results) >= 2:
self.assertGreaterEqual(
results[0]["similarity_score"],
results[1]["similarity_score"],
)