Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
bdc8cd5
KeyFactors votes migration
hlbmtc Oct 2, 2025
2f95278
Votes strength generation
hlbmtc Oct 2, 2025
982bccd
Added no votes log
hlbmtc Oct 2, 2025
446b86f
Added extra comments
hlbmtc Oct 2, 2025
be7cef3
Small fix
hlbmtc Oct 3, 2025
a2a5dd5
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 8, 2025
f13189b
Refactored KeyFactor vote serialization and calculations
hlbmtc Oct 8, 2025
97dbf12
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 8, 2025
558b59b
Fixed conflicts
hlbmtc Oct 8, 2025
85026ec
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 8, 2025
fda1edd
Fixed ImpactDirection type
hlbmtc Oct 8, 2025
e25bc08
Small fix
hlbmtc Oct 14, 2025
46f4cf8
Fixed migration and adjusted votes_unique_user_key_factor constraint
hlbmtc Oct 15, 2025
17e1d38
Adjusted strength formula
hlbmtc Oct 15, 2025
cf7275e
Added Driver.certainty
hlbmtc Oct 15, 2025
7c26829
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 15, 2025
f0a4927
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 16, 2025
940314e
PR review changes
hlbmtc Oct 17, 2025
6392669
Added extra logging
hlbmtc Oct 17, 2025
0740667
Small fix
hlbmtc Oct 17, 2025
2074e02
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 17, 2025
5e709bc
Key Factors: new backend endpoints (#3595)
hlbmtc Oct 17, 2025
9a8e064
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 17, 2025
4a005e9
Small fix
hlbmtc Oct 17, 2025
98ffa98
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 20, 2025
0c9fbf6
Fixed Driver creation validation
hlbmtc Oct 22, 2025
db3a77b
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 22, 2025
52be0f9
Small fix
hlbmtc Oct 22, 2025
00c6fb7
Key Factors V3 Frontend (#3626)
hlbmtc Oct 23, 2025
8601488
Simplified `KeyFactorDraft`
hlbmtc Oct 23, 2025
924a3ed
Adjust ai suggested key factors (#3669)
hlbmtc Oct 24, 2025
0a476db
Merge branch 'main' into feat/3558-key-factors-cleanup-votes
hlbmtc Oct 24, 2025
dc18c83
Key Factors that are migrated from the old system display empty stren…
hlbmtc Oct 24, 2025
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
15 changes: 15 additions & 0 deletions comments/migrations/0019_keyfactors_refactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,19 @@ class Migration(migrations.Migration):
model_name="keyfactor",
name="text_zh_TW",
),
# No real db effect
migrations.AlterField(
model_name="keyfactorvote",
name="score",
field=models.SmallIntegerField(db_index=True),
),
migrations.AlterField(
model_name="keyfactorvote",
name="vote_type",
field=models.CharField(
choices=[("strength", "Strength"), ("up_down", "Up Down")],
default="up_down",
max_length=20,
),
),
]
143 changes: 143 additions & 0 deletions comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Generated by Django 5.1.10 on 2025-10-02 19:31
import logging

from django.db import migrations, models

logger = logging.getLogger(__name__)


def calculate_votes_strength(scores: list[int]):
"""
Calculates overall strengths of the KeyFactor
"""

return (sum(scores) + 2 * max(0, 3 - len(scores))) / max(3, len(scores))


def migrate_strength_vote_score(score: int):
# Converting (-5, -3, -2, 0, 2, 3, 5) scale to (0, 1, 2, 5)
score = abs(score)

return {
0: 0, # No
2: 1, # Low
3: 2, # Medium
5: 5, # High
}[score]


def votes_migration(apps, schema_editor):
KeyFactorVote = apps.get_model("comments", "KeyFactorVote")
KeyFactor = apps.get_model("comments", "KeyFactor")
KeyFactorDriver = apps.get_model("comments", "KeyFactorDriver")

# Drop unused a_updown votes
KeyFactorVote.objects.filter(vote_type="a_updown").delete()

# There could be cases when users might have multiple vote types against one KeyFactor
# So deleting other duplicates
votes_qs = KeyFactorVote.objects.order_by(
"key_factor_id", "user_id", "-created_at"
).distinct("key_factor_id", "user_id")

to_delete = KeyFactorVote.objects.exclude(pk__in=votes_qs)
logger.info(f"Deleting {to_delete.count()} duplicated KeyFactor votes")

to_delete.delete()

# Migrate other votes
key_factors = list(KeyFactor.objects.prefetch_related("votes").all())
update_votes = []
update_drivers = []

for kf in key_factors:
votes = kf.votes.all()

if not votes:
logger.info(f"KeyFactor {kf.id} has no votes")
continue

direction = sum([v.score for v in votes])

if direction == 0:
logger.info(f"KeyFactor {kf.id} has direction = 0")
else:
# Update driver direction
kf.driver.impact_direction = 1 if direction > 0 else 0
update_drivers.append(kf.driver)

for vote in votes:
# Update vote type
vote.vote_type = "strength"

if (
# TODO: double-check 0 case
direction == 0
or (direction > 0 and vote.score < 0)
or (direction < 0 and vote.score > 0)
):
# votes that disagree with the new direction get strength 0
vote.score = 0
else:
# votes that agree keep their abs strength,
# but are converted to the (0, 1, 2, 5) scale
vote.score = migrate_strength_vote_score(vote.score)

update_votes.append(vote)

# Calculate strength
kf.votes_score = (
calculate_votes_strength([v.score for v in votes]) if votes else 0
)

logger.info(f"Updating {len(update_votes)} votes")
KeyFactorVote.objects.bulk_update(update_votes, ["score", "vote_type"])
logger.info(f"Updating {len(update_drivers)} drivers")
KeyFactorDriver.objects.bulk_update(update_drivers, ["impact_direction"])
KeyFactor.objects.bulk_update(key_factors, ["votes_score"])


class Migration(migrations.Migration):
dependencies = [
("comments", "0019_keyfactors_refactor"),
]

operations = [
migrations.AlterField(
model_name="keyfactor",
name="votes_score",
field=models.FloatField(db_index=True, default=0, editable=False),
),
migrations.AlterField(
model_name="keyfactordriver",
name="impact_direction",
field=models.SmallIntegerField(
blank=True, choices=[(1, "Increase"), (-1, "Decrease")], null=True
),
),
migrations.RunPython(votes_migration, reverse_code=migrations.RunPython.noop),
migrations.RemoveConstraint(
model_name="keyfactorvote",
name="votes_unique_user_key_factor",
),
migrations.AddConstraint(
model_name="keyfactorvote",
constraint=models.UniqueConstraint(
fields=("user_id", "key_factor_id"), name="votes_unique_user_key_factor"
),
),
migrations.AlterField(
model_name="keyfactorvote",
name="vote_type",
field=models.CharField(
choices=[("strength", "Strength"), ("direction", "Direction")],
default="direction",
max_length=20,
),
),
migrations.AddField(
model_name="keyfactordriver",
name="certainty",
field=models.FloatField(blank=True, null=True),
),
]
79 changes: 33 additions & 46 deletions comments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,25 +188,39 @@ def for_posts(self, posts: Iterable[Post]):
def filter_active(self):
return self.filter(is_active=True)

def annotate_user_vote(self, user: User):
"""
Annotates queryset with the user's vote option
"""

return self.annotate(
user_vote=Subquery(
KeyFactorVote.objects.filter(
user=user, key_factor=OuterRef("pk")
).values("score")[:1]
),
)

class ImpactDirection(models.TextChoices):
INCREASE = "increase"
DECREASE = "decrease"

class ImpactDirection(models.IntegerChoices):
INCREASE = 1
DECREASE = -1


class KeyFactorDriver(TimeStampedModel, TranslatedModel):
text = models.TextField(blank=True)
impact_direction = models.CharField(
impact_direction = models.SmallIntegerField(
choices=ImpactDirection.choices, null=True, blank=True
)
certainty = models.FloatField(null=True, blank=True)

def __str__(self):
return f"Driver {self.text}"


class KeyFactor(TimeStampedModel):
comment = models.ForeignKey(Comment, models.CASCADE, related_name="key_factors")
votes_score = models.IntegerField(default=0, db_index=True, editable=False)
votes_score = models.FloatField(default=0, db_index=True, editable=False)
is_active = models.BooleanField(default=True, db_index=True)

# If KeyFactor is specifically linked to the subquestion
Expand All @@ -226,40 +240,20 @@ class KeyFactor(TimeStampedModel):
KeyFactorDriver, models.PROTECT, related_name="key_factor", null=True
)

def get_votes_score(self) -> int:
"""
Aggregate function applies only to A-type Votes.
B and C types can't be aggregated this way, so we exclude them for now.
TODO: This may need to be revisited in the future for broader vote type support.
"""

return (
self.votes.filter(vote_type=KeyFactorVote.VoteType.A_UPVOTE_DOWNVOTE)
.aggregate(Sum("score"))
.get("score__sum")
or 0
)

def get_votes_count(self) -> int:
"""
Counts the number of votes for the key factor
"""
return self.votes.aggregate(Count("id")).get("id__count") or 0

def update_vote_score(self):
self.votes_score = self.get_votes_score()
self.save(update_fields=["votes_score"])

return self.votes_score

objects = models.Manager.from_queryset(KeyFactorQuerySet)()

# Annotated placeholders
vote_type: str = None

def __str__(self):
return f"KeyFactor {getattr(self.comment.on_post, 'title', None)}"

# Annotated fields
user_vote: int = None

class Meta:
# Used to get rid of the type error which complains
# about the two Meta classes in the 2 parent classes
Expand All @@ -268,39 +262,32 @@ class Meta:

class KeyFactorVote(TimeStampedModel):
class VoteType(models.TextChoices):
A_UPVOTE_DOWNVOTE = "a_updown"
B_TWO_STEP_SURVEY = "b_2step"
C_LIKERT_SCALE = "c_likert"
STRENGTH = "strength"
DIRECTION = "direction"

class VoteScore(models.IntegerChoices):
class VoteDirection(models.IntegerChoices):
UP = 1
DOWN = -1
# Using a simple integer value to encode scores for both B and C options
# B and C are conceptually on different scales than A, because they should
# capture the change in probability caused by the key factor, and not whether
# the key factor is relevant or not (as the UP/DOWN vote type does)
# But we do use the same field to store these given this is temporary and simpler.
DECREASE_HIGH = -5
DECREASE_MEDIUM = -3
DECREASE_LOW = -2

class VoteStrength(models.IntegerChoices):
NO_IMPACT = 0
INCREASE_LOW = 2
INCREASE_MEDIUM = 3
INCREASE_HIGH = 5
LOW_STRENGTH = 1
MEDIUM_STRENGTH = 2
HIGH_STRENGTH = 5

user = models.ForeignKey(User, models.CASCADE, related_name="key_factor_votes")
key_factor = models.ForeignKey(KeyFactor, models.CASCADE, related_name="votes")
score = models.SmallIntegerField(choices=VoteScore.choices, db_index=True)
score = models.SmallIntegerField(db_index=True)
# This field will be removed once we decide on the type of vote
vote_type = models.CharField(
choices=VoteType.choices, max_length=20, default=VoteType.A_UPVOTE_DOWNVOTE
choices=VoteType.choices, max_length=20, default=VoteType.DIRECTION
)

class Meta:
constraints = [
models.UniqueConstraint(
name="votes_unique_user_key_factor",
fields=["user_id", "key_factor_id", "vote_type"],
fields=["user_id", "key_factor_id"],
)
]
indexes = [
Expand Down
6 changes: 5 additions & 1 deletion comments/serializers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ def serialize_comment_many(
current_user: User | None = None,
with_key_factors: bool = False,
) -> list[dict]:
current_user = (
current_user if current_user and current_user.is_authenticated else None
)

# Get original ordering of the comments
ids = [p.pk for p in comments]
qs = Comment.objects.filter(pk__in=[c.pk for c in comments])
Expand All @@ -167,7 +171,7 @@ def serialize_comment_many(
).prefetch_related("key_factors")
qs = qs.annotate_vote_score()

if current_user and not current_user.is_anonymous:
if current_user:
qs = qs.annotate_user_vote(current_user)

qs = qs.annotate_cmm_info(current_user)
Expand Down
Loading
Loading