feat(learnings): added learnings business logic
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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"],
|
||||
)
|
||||
Reference in New Issue
Block a user