From bdc8cd5292f0a467048dd3743b4b8de708a86a5f Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 2 Oct 2025 17:28:12 -0300 Subject: [PATCH 01/23] KeyFactors votes migration --- .../migrations/0019_keyfactors_refactor.py | 15 ++++ ..._keyfactors_votes_migration_and_cleanup.py | 86 +++++++++++++++++++ comments/models.py | 35 +++----- comments/views.py | 2 +- tests/unit/test_comments/test_serializers.py | 6 +- tests/unit/test_comments/test_services.py | 8 +- 6 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py diff --git a/comments/migrations/0019_keyfactors_refactor.py b/comments/migrations/0019_keyfactors_refactor.py index da721a6aaf..167efa95fa 100644 --- a/comments/migrations/0019_keyfactors_refactor.py +++ b/comments/migrations/0019_keyfactors_refactor.py @@ -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, + ), + ), ] diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py new file mode 100644 index 0000000000..44f3613333 --- /dev/null +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -0,0 +1,86 @@ +# Generated by Django 5.1.10 on 2025-10-02 19:31 +import logging + +from django.db import migrations + +logger = logging.getLogger(__name__) + + +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 { + # No + 0: 0, + # Low + 2: 1, + # Medium + 3: 2, + # Heigh + 5: 5, + }[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(type="a_updown").delete() + + # Migrate other votes + key_factors = KeyFactor.objects.prefetch_related("votes").all() + update_votes = [] + update_drivers = [] + + for kf in key_factors: + votes = kf.votes.all() + + 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 = "increase" if direction > 0 else "decrease" + update_drivers.append(kf) + + logger.info(f"KeyFactor {kf.id} has direction = {direction}") + + 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(update_votes) + + logger.info(f"Updating {len(update_votes)} votes") + KeyFactorVote.objects.bulk_update(update_votes, ["score"]) + logger.info(f"Updating {len(update_drivers)} drivers") + KeyFactorDriver.objects.bulk_update( + update_drivers, ["impact_direction", "vote_type"] + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("comments", "0019_keyfactors_refactor"), + ] + + operations = [ + migrations.RunPython(votes_migration, reverse_code=migrations.RunPython.noop), + ] diff --git a/comments/models.py b/comments/models.py index 09816045d4..ed55636156 100644 --- a/comments/models.py +++ b/comments/models.py @@ -233,12 +233,7 @@ def get_votes_score(self) -> int: 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 - ) + return self.votes.aggregate(Sum("score")).get("score__sum") or 0 def get_votes_count(self) -> int: """ @@ -247,6 +242,7 @@ def get_votes_count(self) -> int: return self.votes.aggregate(Count("id")).get("id__count") or 0 def update_vote_score(self): + # TODO: different algorithm for strength and up/down self.votes_score = self.get_votes_score() self.save(update_fields=["votes_score"]) @@ -268,32 +264,25 @@ 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" + UP_DOWN = "up_down" - class VoteScore(models.IntegerChoices): + class VoteScoreUpDown(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.UP_DOWN ) class Meta: diff --git a/comments/views.py b/comments/views.py index a72b4b2e5f..94290ea060 100644 --- a/comments/views.py +++ b/comments/views.py @@ -303,7 +303,7 @@ def comment_create_oldapi_view(request: Request): def key_factor_vote_view(request: Request, pk: int): key_factor = get_object_or_404(KeyFactor, pk=pk) vote = serializers.ChoiceField( - required=False, allow_null=True, choices=KeyFactorVote.VoteScore.choices + required=False, allow_null=True, choices=KeyFactorVote.VoteStrength.choices ).run_validation(request.data.get("vote")) # vote_type is always required, and when vote is None, the type is being used to # decide which vote to delete based on the type diff --git a/tests/unit/test_comments/test_serializers.py b/tests/unit/test_comments/test_serializers.py index 28b236acc6..0799ff249d 100644 --- a/tests/unit/test_comments/test_serializers.py +++ b/tests/unit/test_comments/test_serializers.py @@ -17,15 +17,15 @@ def test_serialize_key_factors_many(user1, user2): driver=KeyFactorDriver.objects.create(text_en="Key Factor Text"), votes={user1: 1, user2: -1, user3: -1}, votes_score=-1, - vote_type=KeyFactorVote.VoteType.A_UPVOTE_DOWNVOTE, + vote_type=KeyFactorVote.VoteType.UP_DOWN, ) # Test votes of the new types KeyFactorVote.objects.create( key_factor=kf, - score=KeyFactorVote.VoteScore.INCREASE_HIGH, + score=KeyFactorVote.VoteStrength.HIGH_STRENGTH, user=user1, - vote_type=KeyFactorVote.VoteType.C_LIKERT_SCALE, + vote_type=KeyFactorVote.VoteType.STRENGTH, ) data = serialize_key_factors_many([kf], current_user=user1) diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index 006d880626..7e772321e0 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -114,22 +114,22 @@ def test_key_factor_vote(user1, user2): comment=comment, driver=KeyFactorDriver.objects.create(text="Key Factor Text"), votes={user2: -1}, - vote_type=KeyFactorVote.VoteType.A_UPVOTE_DOWNVOTE, + vote_type=KeyFactorVote.VoteType.UP_DOWN, ) assert ( key_factor_vote( - kf, user1, vote=-1, vote_type=KeyFactorVote.VoteType.A_UPVOTE_DOWNVOTE + kf, user1, vote=-1, vote_type=KeyFactorVote.VoteType.UP_DOWN ) == -2 ) assert ( - key_factor_vote(kf, user1, vote_type=KeyFactorVote.VoteType.A_UPVOTE_DOWNVOTE) + key_factor_vote(kf, user1, vote_type=KeyFactorVote.VoteType.UP_DOWN) == -1 ) assert ( key_factor_vote( - kf, user1, vote=1, vote_type=KeyFactorVote.VoteType.A_UPVOTE_DOWNVOTE + kf, user1, vote=1, vote_type=KeyFactorVote.VoteType.UP_DOWN ) == 0 ) From 2f95278b196f28f01720337c5031f5016c2bcac6 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 2 Oct 2025 17:40:12 -0300 Subject: [PATCH 02/23] Votes strength generation --- ...20_keyfactors_votes_migration_and_cleanup.py | 14 +++++++++++++- comments/models.py | 16 ---------------- comments/services/key_factors.py | 17 +++++++++++++++-- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 44f3613333..a8620b8830 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -6,6 +6,14 @@ logger = logging.getLogger(__name__) +def calculate_votes_strength(scores: list[int]): + """ + Calculates overall strengths of the KeyFactor + """ + + return (sum(scores) + 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) @@ -31,7 +39,7 @@ def votes_migration(apps, schema_editor): KeyFactorVote.objects.filter(type="a_updown").delete() # Migrate other votes - key_factors = KeyFactor.objects.prefetch_related("votes").all() + key_factors = list(KeyFactor.objects.prefetch_related("votes").all()) update_votes = [] update_drivers = [] @@ -68,12 +76,16 @@ def votes_migration(apps, schema_editor): update_votes.append(update_votes) + # Calculate strength + kf.votes_score = calculate_votes_strength([v.score for v in votes]) + logger.info(f"Updating {len(update_votes)} votes") KeyFactorVote.objects.bulk_update(update_votes, ["score"]) logger.info(f"Updating {len(update_drivers)} drivers") KeyFactorDriver.objects.bulk_update( update_drivers, ["impact_direction", "vote_type"] ) + KeyFactor.objects.bulk_update(key_factors, ["votes_score"]) class Migration(migrations.Migration): diff --git a/comments/models.py b/comments/models.py index ed55636156..0fe1ca43c1 100644 --- a/comments/models.py +++ b/comments/models.py @@ -226,28 +226,12 @@ 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.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): - # TODO: different algorithm for strength and up/down - 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 diff --git a/comments/services/key_factors.py b/comments/services/key_factors.py index 5abccd3dbe..7b4238b8ac 100644 --- a/comments/services/key_factors.py +++ b/comments/services/key_factors.py @@ -16,7 +16,7 @@ def key_factor_vote( user: User, vote: int = None, vote_type: KeyFactorVote.VoteType = None, -) -> dict[int, int]: +) -> float: # Deleting existing vote for this vote type key_factor.votes.filter(user=user, vote_type=vote_type).delete() @@ -24,7 +24,12 @@ def key_factor_vote( key_factor.votes.create(user=user, score=vote, vote_type=vote_type) # Update counters - return key_factor.update_vote_score() + key_factor.votes_score = calculate_votes_strength( + list(key_factor.votes.values_list("score", flat=True)) + ) + key_factor.save(update_fields=["votes_score"]) + + return key_factor.votes_score def get_user_votes_for_key_factors( @@ -87,3 +92,11 @@ def generate_keyfactors_for_comment( comment_text, existing_keyfactors, ) + + +def calculate_votes_strength(scores: list[int]): + """ + Calculates overall strengths of the KeyFactor + """ + + return (sum(scores) + max(0, 3 - len(scores))) / max(3, len(scores)) From 982bccd94b7cada91430cd34f078cae489c68015 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 2 Oct 2025 17:43:33 -0300 Subject: [PATCH 03/23] Added no votes log --- .../migrations/0020_keyfactors_votes_migration_and_cleanup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index a8620b8830..28b9ebfc57 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -46,6 +46,10 @@ def votes_migration(apps, schema_editor): 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: From 446b86fda4c0071e0b56363be23dd780f0a022ed Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 2 Oct 2025 17:46:20 -0300 Subject: [PATCH 04/23] Added extra comments --- comments/services/key_factors.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/comments/services/key_factors.py b/comments/services/key_factors.py index 7b4238b8ac..deb81b7483 100644 --- a/comments/services/key_factors.py +++ b/comments/services/key_factors.py @@ -24,6 +24,10 @@ def key_factor_vote( key_factor.votes.create(user=user, score=vote, vote_type=vote_type) # Update counters + # For now, we generate `strength` for all key factor types. + # This is mainly for simplicity — only Drivers and News actually use `strength`, + # while BaseRate doesn't require vote score calculations. + # So it’s easier and more consistent to apply the same logic across all key factors, even if some don’t use it. key_factor.votes_score = calculate_votes_strength( list(key_factor.votes.values_list("score", flat=True)) ) From be7cef3a91dc6905a2d7bc0d3c60503c00a4deb0 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 3 Oct 2025 10:38:33 -0300 Subject: [PATCH 05/23] Small fix --- .../0020_keyfactors_votes_migration_and_cleanup.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 28b9ebfc57..5cfc0aa5f4 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -36,7 +36,7 @@ def votes_migration(apps, schema_editor): KeyFactorDriver = apps.get_model("comments", "KeyFactorDriver") # Drop unused a_updown votes - KeyFactorVote.objects.filter(type="a_updown").delete() + KeyFactorVote.objects.filter(vote_type="a_updown").delete() # Migrate other votes key_factors = list(KeyFactor.objects.prefetch_related("votes").all()) @@ -57,7 +57,7 @@ def votes_migration(apps, schema_editor): else: # Update driver direction kf.driver.impact_direction = "increase" if direction > 0 else "decrease" - update_drivers.append(kf) + update_drivers.append(kf.driver) logger.info(f"KeyFactor {kf.id} has direction = {direction}") @@ -84,11 +84,9 @@ def votes_migration(apps, schema_editor): kf.votes_score = calculate_votes_strength([v.score for v in votes]) logger.info(f"Updating {len(update_votes)} votes") - KeyFactorVote.objects.bulk_update(update_votes, ["score"]) + 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", "vote_type"] - ) + KeyFactorDriver.objects.bulk_update(update_drivers, ["impact_direction"]) KeyFactor.objects.bulk_update(key_factors, ["votes_score"]) From f13189bb503a577be81da9d4d25d414e0d658c7e Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 8 Oct 2025 16:44:27 +0200 Subject: [PATCH 06/23] Refactored KeyFactor vote serialization and calculations --- comments/models.py | 19 ++++++-- comments/serializers.py | 47 +++++++++++--------- comments/services/key_factors.py | 16 ++++--- tests/unit/test_comments/test_serializers.py | 18 +++----- tests/unit/test_comments/test_services.py | 34 +++++++------- 5 files changed, 75 insertions(+), 59 deletions(-) diff --git a/comments/models.py b/comments/models.py index 0fe1ca43c1..75b383dee0 100644 --- a/comments/models.py +++ b/comments/models.py @@ -188,6 +188,19 @@ 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" @@ -234,12 +247,12 @@ def get_votes_count(self) -> int: 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 diff --git a/comments/serializers.py b/comments/serializers.py index fa516e92f6..c88b68279c 100644 --- a/comments/serializers.py +++ b/comments/serializers.py @@ -1,13 +1,13 @@ +from collections import Counter from typing import Iterable -from django.db.models import Count from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from comments.models import Comment, KeyFactor, KeyFactorVote, CommentsOfTheWeekEntry -from comments.services.key_factors import get_user_votes_for_key_factors +from comments.models import Comment, KeyFactor, CommentsOfTheWeekEntry +from comments.services.key_factors import get_votes_for_key_factors from comments.utils import comments_extract_user_mentions_mapping from posts.models import Post from posts.services.common import get_posts_staff_users @@ -159,6 +159,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]) @@ -168,7 +172,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) @@ -208,23 +212,26 @@ def serialize_comment_many( ] -def serialize_key_factor( - key_factor: KeyFactor, user_votes: list[KeyFactorVote] = None -) -> dict: - user_votes = user_votes or [] +def serialize_key_factor_votes(key_factor: KeyFactor, vote_scores: list[int]): + pivot_votes = Counter(vote_scores) + + return { + "score": key_factor.votes_score, + "aggregated_data": [ + {"score": score, "count": count} for score, count in pivot_votes.items() + ], + "user_vote": key_factor.user_vote, + } + +def serialize_key_factor(key_factor: KeyFactor, vote_scores: list[int] = None) -> dict: return { "id": key_factor.id, "driver": {"text": key_factor.driver.text} if key_factor.driver else None, "author": BaseUserSerializer(key_factor.comment.author).data, "comment_id": key_factor.comment_id, "post_id": key_factor.comment.on_post_id, - "user_votes": [ - {"vote_type": vote.vote_type, "score": vote.score} for vote in user_votes - ], - "votes_score": key_factor.votes_score, - "votes_count": getattr(key_factor, "votes_count"), - "vote_type": key_factor.vote_type, + "vote": serialize_key_factor_votes(key_factor, vote_scores or []), } @@ -237,22 +244,20 @@ def serialize_key_factors_many( KeyFactor.objects.filter(pk__in=ids) .filter_active() .select_related("comment__author", "driver") - .annotate(votes_count=Count("votes")) ) + if current_user: + qs = qs.annotate_user_vote(current_user) + # Restore the original ordering objects = list(qs.all()) objects.sort(key=lambda obj: ids.index(obj.id)) # Extract user votes - user_votes_map = ( - get_user_votes_for_key_factors(key_factors, current_user) - if current_user and not current_user.is_anonymous - else {} - ) + votes_map = get_votes_for_key_factors(key_factors) return [ - serialize_key_factor(key_factor, user_votes=user_votes_map.get(key_factor.id)) + serialize_key_factor(key_factor, vote_scores=votes_map.get(key_factor.id)) for key_factor in objects ] diff --git a/comments/services/key_factors.py b/comments/services/key_factors.py index deb81b7483..d2e118aad6 100644 --- a/comments/services/key_factors.py +++ b/comments/services/key_factors.py @@ -1,3 +1,4 @@ +from collections import defaultdict from typing import Iterable from django.db import transaction @@ -6,7 +7,6 @@ from comments.models import KeyFactor, KeyFactorVote, Comment, KeyFactorDriver from posts.models import Post from users.models import User -from utils.dtypes import generate_map_from_list from utils.openai import generate_keyfactors @@ -36,16 +36,20 @@ def key_factor_vote( return key_factor.votes_score -def get_user_votes_for_key_factors( - key_factors: Iterable[KeyFactor], user: User -) -> dict[int, list[KeyFactor]]: +def get_votes_for_key_factors(key_factors: Iterable[KeyFactor]) -> dict[int, list[int]]: """ Generates map of user votes for a set of KeyFactors """ - votes = KeyFactorVote.objects.filter(key_factor__in=key_factors, user=user) + votes = KeyFactorVote.objects.filter(key_factor__in=key_factors).only( + "key_factor_id", "score" + ) + votes_map = defaultdict(list) + + for vote in votes: + votes_map[vote.key_factor_id].append(vote.score) - return generate_map_from_list(list(votes), key=lambda vote: vote.key_factor_id) + return votes_map @transaction.atomic diff --git a/tests/unit/test_comments/test_serializers.py b/tests/unit/test_comments/test_serializers.py index 0799ff249d..2652cce298 100644 --- a/tests/unit/test_comments/test_serializers.py +++ b/tests/unit/test_comments/test_serializers.py @@ -20,21 +20,15 @@ def test_serialize_key_factors_many(user1, user2): vote_type=KeyFactorVote.VoteType.UP_DOWN, ) - # Test votes of the new types - KeyFactorVote.objects.create( - key_factor=kf, - score=KeyFactorVote.VoteStrength.HIGH_STRENGTH, - user=user1, - vote_type=KeyFactorVote.VoteType.STRENGTH, - ) - data = serialize_key_factors_many([kf], current_user=user1) assert data[0]["id"] == kf.id assert data[0]["driver"]["text"] == "Key Factor Text" - assert data[0]["user_votes"] == [ - {"vote_type": "a_updown", "score": 1}, - {"vote_type": "c_likert", "score": 5}, + assert data[0]["vote"] + assert data[0]["vote"]["aggregated_data"] == [ + {"score": 1, "count": 1}, + {"score": -1, "count": 2}, ] - assert data[0]["votes_score"] == -1 + assert data[0]["vote"]["user_vote"] == 1 + assert data[0]["vote"]["score"] == -1 assert data[0]["author"]["id"] == user1.id diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index 7e772321e0..b7ac75a969 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -109,30 +109,30 @@ def test_notify_mentioned_users( def test_key_factor_vote(user1, user2): + user3 = factory_user() + user4 = factory_user() comment = factory_comment(author=user1, on_post=factory_post(author=user1)) kf = factory_key_factor( comment=comment, driver=KeyFactorDriver.objects.create(text="Key Factor Text"), - votes={user2: -1}, - vote_type=KeyFactorVote.VoteType.UP_DOWN, + votes={user1: 1}, + vote_type=KeyFactorVote.VoteType.STRENGTH, ) - assert ( - key_factor_vote( - kf, user1, vote=-1, vote_type=KeyFactorVote.VoteType.UP_DOWN - ) - == -2 - ) - assert ( - key_factor_vote(kf, user1, vote_type=KeyFactorVote.VoteType.UP_DOWN) - == -1 - ) - assert ( - key_factor_vote( - kf, user1, vote=1, vote_type=KeyFactorVote.VoteType.UP_DOWN + def assert_vote(u, vt, expected): + assert ( + key_factor_vote(kf, u, vote=vt, vote_type=KeyFactorVote.VoteType.STRENGTH) + == expected ) - == 0 - ) + + assert_vote(user3, 5, pytest.approx(2.33, abs=0.01)) + assert_vote(user2, 2, pytest.approx(2.66, abs=0.01)) + # Remove vote + assert_vote(user2, None, pytest.approx(2.33, abs=0.01)) + # Add neutral vote + assert_vote(user2, 0, 2) + # Add 4th vote + assert_vote(user4, 5, 2.75) def test_soft_delete_comment(user1, user2, post): From 558b59b6f63fab140e392f8f1e90414901dfbca2 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 8 Oct 2025 17:50:49 +0200 Subject: [PATCH 07/23] Fixed conflicts --- comments/serializers/common.py | 3 --- comments/serializers/key_factors.py | 42 ++++++++++++++--------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/comments/serializers/common.py b/comments/serializers/common.py index febb72682d..f86b1f9e32 100644 --- a/comments/serializers/common.py +++ b/comments/serializers/common.py @@ -1,4 +1,3 @@ -from collections import Counter from typing import Iterable from django.db.models.query import QuerySet @@ -7,8 +6,6 @@ from rest_framework.exceptions import ValidationError from comments.models import Comment, KeyFactor, CommentsOfTheWeekEntry -from comments.models import Comment, KeyFactor, CommentsOfTheWeekEntry -from comments.services.key_factors import get_votes_for_key_factors from comments.utils import comments_extract_user_mentions_mapping from posts.models import Post from posts.services.common import get_posts_staff_users diff --git a/comments/serializers/key_factors.py b/comments/serializers/key_factors.py index 66f22f22eb..18044026a8 100644 --- a/comments/serializers/key_factors.py +++ b/comments/serializers/key_factors.py @@ -1,30 +1,32 @@ +from collections import Counter from typing import Iterable -from django.db.models import Count - -from comments.models import KeyFactor, KeyFactorVote -from comments.services.key_factors import get_user_votes_for_key_factors +from comments.models import KeyFactor +from comments.services.key_factors import get_votes_for_key_factors from users.models import User from users.serializers import BaseUserSerializer -def serialize_key_factor( - key_factor: KeyFactor, user_votes: list[KeyFactorVote] = None -) -> dict: - user_votes = user_votes or [] +def serialize_key_factor_votes(key_factor: KeyFactor, vote_scores: list[int]): + pivot_votes = Counter(vote_scores) + return { + "score": key_factor.votes_score, + "aggregated_data": [ + {"score": score, "count": count} for score, count in pivot_votes.items() + ], + "user_vote": key_factor.user_vote, + } + + +def serialize_key_factor(key_factor: KeyFactor, vote_scores: list[int] = None) -> dict: return { "id": key_factor.id, "driver": {"text": key_factor.driver.text} if key_factor.driver else None, "author": BaseUserSerializer(key_factor.comment.author).data, "comment_id": key_factor.comment_id, "post_id": key_factor.comment.on_post_id, - "user_votes": [ - {"vote_type": vote.vote_type, "score": vote.score} for vote in user_votes - ], - "votes_score": key_factor.votes_score, - "votes_count": getattr(key_factor, "votes_count"), - "vote_type": key_factor.vote_type, + "vote": serialize_key_factor_votes(key_factor, vote_scores or []), } @@ -37,21 +39,19 @@ def serialize_key_factors_many( KeyFactor.objects.filter(pk__in=ids) .filter_active() .select_related("comment__author", "driver") - .annotate(votes_count=Count("votes")) ) + if current_user: + qs = qs.annotate_user_vote(current_user) + # Restore the original ordering objects = list(qs.all()) objects.sort(key=lambda obj: ids.index(obj.id)) # Extract user votes - user_votes_map = ( - get_user_votes_for_key_factors(key_factors, current_user) - if current_user and not current_user.is_anonymous - else {} - ) + votes_map = get_votes_for_key_factors(key_factors) return [ - serialize_key_factor(key_factor, user_votes=user_votes_map.get(key_factor.id)) + serialize_key_factor(key_factor, vote_scores=votes_map.get(key_factor.id)) for key_factor in objects ] From fda1edd2c3dc75924d9f48902910e9df5d7b3e02 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 8 Oct 2025 19:20:54 +0200 Subject: [PATCH 08/23] Fixed ImpactDirection type --- .../0020_keyfactors_votes_migration_and_cleanup.py | 11 +++++++++-- comments/models.py | 8 ++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 5cfc0aa5f4..ecc2b39a79 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -1,7 +1,7 @@ # Generated by Django 5.1.10 on 2025-10-02 19:31 import logging -from django.db import migrations +from django.db import migrations, models logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def votes_migration(apps, schema_editor): logger.info(f"KeyFactor {kf.id} has direction = 0") else: # Update driver direction - kf.driver.impact_direction = "increase" if direction > 0 else "decrease" + kf.driver.impact_direction = 1 if direction > 0 else 0 update_drivers.append(kf.driver) logger.info(f"KeyFactor {kf.id} has direction = {direction}") @@ -96,5 +96,12 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterField( + model_name="keyfactordriver", + name="impact_direction", + field=models.IntegerField( + blank=True, choices=[(1, "Increase"), (-1, "Decrease")], null=True + ), + ), migrations.RunPython(votes_migration, reverse_code=migrations.RunPython.noop), ] diff --git a/comments/models.py b/comments/models.py index 75b383dee0..f83fdad090 100644 --- a/comments/models.py +++ b/comments/models.py @@ -202,14 +202,14 @@ def annotate_user_vote(self, user: User): ) -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.IntegerField( choices=ImpactDirection.choices, null=True, blank=True ) From e25bc0815f30bb2a65201a39b2f330d972ffc23e Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 14 Oct 2025 19:56:34 +0200 Subject: [PATCH 09/23] Small fix --- .../0020_keyfactors_votes_migration_and_cleanup.py | 5 +++++ comments/models.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index ecc2b39a79..251c610db5 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -96,6 +96,11 @@ class Migration(migrations.Migration): ] 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", diff --git a/comments/models.py b/comments/models.py index f83fdad090..7db033634a 100644 --- a/comments/models.py +++ b/comments/models.py @@ -219,7 +219,7 @@ def __str__(self): 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 From 46f4cf8d67b8ebe141bd498c6343616e1ad82d02 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 15 Oct 2025 12:37:49 +0200 Subject: [PATCH 10/23] Fixed migration and adjusted votes_unique_user_key_factor constraint --- ..._keyfactors_votes_migration_and_cleanup.py | 21 ++++++++++++++++--- comments/models.py | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 251c610db5..428f0e1a05 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -38,6 +38,13 @@ def votes_migration(apps, schema_editor): # 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") + KeyFactorVote.objects.exclude(pk__in=votes_qs).delete() + # Migrate other votes key_factors = list(KeyFactor.objects.prefetch_related("votes").all()) update_votes = [] @@ -59,8 +66,6 @@ def votes_migration(apps, schema_editor): kf.driver.impact_direction = 1 if direction > 0 else 0 update_drivers.append(kf.driver) - logger.info(f"KeyFactor {kf.id} has direction = {direction}") - for vote in votes: # Update vote type vote.vote_type = "strength" @@ -78,7 +83,7 @@ def votes_migration(apps, schema_editor): # but are converted to the (0, 1, 2, 5) scale vote.score = migrate_strength_vote_score(vote.score) - update_votes.append(update_votes) + update_votes.append(vote) # Calculate strength kf.votes_score = calculate_votes_strength([v.score for v in votes]) @@ -109,4 +114,14 @@ class Migration(migrations.Migration): ), ), 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" + ), + ), ] diff --git a/comments/models.py b/comments/models.py index 7db033634a..e260028dd8 100644 --- a/comments/models.py +++ b/comments/models.py @@ -286,7 +286,7 @@ 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 = [ From 17e1d3841f0c7706fdbd5e569c061289ea263d99 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 15 Oct 2025 15:01:23 +0200 Subject: [PATCH 11/23] Adjusted strength formula --- .../migrations/0020_keyfactors_votes_migration_and_cleanup.py | 2 +- comments/services/key_factors.py | 2 +- tests/unit/test_comments/test_services.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 428f0e1a05..95fcc78ef0 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -11,7 +11,7 @@ def calculate_votes_strength(scores: list[int]): Calculates overall strengths of the KeyFactor """ - return (sum(scores) + max(0, 3 - len(scores))) / max(3, len(scores)) + return (sum(scores) + 2 * max(0, 3 - len(scores))) / max(3, len(scores)) def migrate_strength_vote_score(score: int): diff --git a/comments/services/key_factors.py b/comments/services/key_factors.py index d2e118aad6..f7751bab85 100644 --- a/comments/services/key_factors.py +++ b/comments/services/key_factors.py @@ -107,4 +107,4 @@ def calculate_votes_strength(scores: list[int]): Calculates overall strengths of the KeyFactor """ - return (sum(scores) + max(0, 3 - len(scores))) / max(3, len(scores)) + return (sum(scores) + 2 * max(0, 3 - len(scores))) / max(3, len(scores)) diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index b7ac75a969..9d6b864987 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -125,10 +125,10 @@ def assert_vote(u, vt, expected): == expected ) - assert_vote(user3, 5, pytest.approx(2.33, abs=0.01)) + assert_vote(user3, 5, pytest.approx(2.66, abs=0.01)) assert_vote(user2, 2, pytest.approx(2.66, abs=0.01)) # Remove vote - assert_vote(user2, None, pytest.approx(2.33, abs=0.01)) + assert_vote(user2, None, pytest.approx(2.66, abs=0.01)) # Add neutral vote assert_vote(user2, 0, 2) # Add 4th vote From cf7275e659077e3370132874d157081d69bfd85e Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 15 Oct 2025 16:56:27 +0200 Subject: [PATCH 12/23] Added Driver.certainty --- .../0020_keyfactors_votes_migration_and_cleanup.py | 7 ++++++- comments/models.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 95fcc78ef0..9baaa02a50 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -109,7 +109,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="keyfactordriver", name="impact_direction", - field=models.IntegerField( + field=models.SmallIntegerField( blank=True, choices=[(1, "Increase"), (-1, "Decrease")], null=True ), ), @@ -124,4 +124,9 @@ class Migration(migrations.Migration): fields=("user_id", "key_factor_id"), name="votes_unique_user_key_factor" ), ), + migrations.AddField( + model_name="keyfactordriver", + name="certainty", + field=models.SmallIntegerField(blank=True, null=True), + ), ] diff --git a/comments/models.py b/comments/models.py index e260028dd8..19d147ec83 100644 --- a/comments/models.py +++ b/comments/models.py @@ -209,9 +209,10 @@ class ImpactDirection(models.IntegerChoices): class KeyFactorDriver(TimeStampedModel, TranslatedModel): text = models.TextField(blank=True) - impact_direction = models.IntegerField( + impact_direction = models.SmallIntegerField( choices=ImpactDirection.choices, null=True, blank=True ) + certainty = models.SmallIntegerField(null=True, blank=True) def __str__(self): return f"Driver {self.text}" From 940314ef98c3fdafa76ec150f2acd806ff3b7831 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 17 Oct 2025 15:15:17 +0200 Subject: [PATCH 13/23] PR review changes --- ..._keyfactors_votes_migration_and_cleanup.py | 23 +++++++++++-------- comments/models.py | 8 +++---- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 9baaa02a50..ba62c768f3 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -19,14 +19,10 @@ def migrate_strength_vote_score(score: int): score = abs(score) return { - # No - 0: 0, - # Low - 2: 1, - # Medium - 3: 2, - # Heigh - 5: 5, + 0: 0, # No + 2: 1, # Low + 3: 2, # Medium + 5: 5, # High }[score] @@ -124,9 +120,18 @@ class Migration(migrations.Migration): 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.SmallIntegerField(blank=True, null=True), + field=models.FloatField(blank=True, null=True), ), ] diff --git a/comments/models.py b/comments/models.py index 19d147ec83..a4e97b2d0e 100644 --- a/comments/models.py +++ b/comments/models.py @@ -212,7 +212,7 @@ class KeyFactorDriver(TimeStampedModel, TranslatedModel): impact_direction = models.SmallIntegerField( choices=ImpactDirection.choices, null=True, blank=True ) - certainty = models.SmallIntegerField(null=True, blank=True) + certainty = models.FloatField(null=True, blank=True) def __str__(self): return f"Driver {self.text}" @@ -263,9 +263,9 @@ class Meta: class KeyFactorVote(TimeStampedModel): class VoteType(models.TextChoices): STRENGTH = "strength" - UP_DOWN = "up_down" + DIRECTION = "direction" - class VoteScoreUpDown(models.IntegerChoices): + class VoteDirection(models.IntegerChoices): UP = 1 DOWN = -1 @@ -280,7 +280,7 @@ class VoteStrength(models.IntegerChoices): 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.UP_DOWN + choices=VoteType.choices, max_length=20, default=VoteType.DIRECTION ) class Meta: From 6392669a921bcba821e763d73bc09a521f6e87d1 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 17 Oct 2025 15:16:36 +0200 Subject: [PATCH 14/23] Added extra logging --- .../0020_keyfactors_votes_migration_and_cleanup.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index ba62c768f3..6f7f0f1c84 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -39,7 +39,11 @@ def votes_migration(apps, schema_editor): votes_qs = KeyFactorVote.objects.order_by( "key_factor_id", "user_id", "-created_at" ).distinct("key_factor_id", "user_id") - KeyFactorVote.objects.exclude(pk__in=votes_qs).delete() + + 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()) From 0740667c207b506b63b2afd2e2c598e3c80ce130 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 17 Oct 2025 15:25:23 +0200 Subject: [PATCH 15/23] Small fix --- tests/unit/test_comments/test_serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_comments/test_serializers.py b/tests/unit/test_comments/test_serializers.py index cd9582c96e..f03d1f5601 100644 --- a/tests/unit/test_comments/test_serializers.py +++ b/tests/unit/test_comments/test_serializers.py @@ -17,7 +17,7 @@ def test_serialize_key_factors_many(user1, user2): driver=KeyFactorDriver.objects.create(text_en="Key Factor Text"), votes={user1: 1, user2: -1, user3: -1}, votes_score=-1, - vote_type=KeyFactorVote.VoteType.UP_DOWN, + vote_type=KeyFactorVote.VoteType.DIRECTION, ) data = serialize_key_factors_many([kf], current_user=user1) From 5e709bc19ce9c15d905adfa54843d9f313616962 Mon Sep 17 00:00:00 2001 From: Hlib Date: Fri, 17 Oct 2025 15:42:11 +0200 Subject: [PATCH 16/23] Key Factors: new backend endpoints (#3595) * Refactored KeyFactor creation endpoints and created extra services * Small fix * Removed extra todo * Implemented KeyFactor deletion endpoint * utils/the_math/aggregations.py * Small fix * Small fix * Adjusted keyFactor vote view * Set default key factor strength to 1 * Backend: return question label object * Small fix * Added Driver.certainty --- comments/serializers/key_factors.py | 95 ++++++++++-- comments/services/key_factors.py | 170 ++++++++++++++++++++-- comments/urls.py | 5 + comments/views/common.py | 7 +- comments/views/key_factors.py | 43 ++++-- projects/permissions.py | 14 ++ tests/unit/test_comments/test_services.py | 89 ++++++++++- tests/unit/test_comments/test_views.py | 32 +++- utils/datetime.py | 15 ++ 9 files changed, 430 insertions(+), 40 deletions(-) create mode 100644 utils/datetime.py diff --git a/comments/serializers/key_factors.py b/comments/serializers/key_factors.py index 18044026a8..877e33856a 100644 --- a/comments/serializers/key_factors.py +++ b/comments/serializers/key_factors.py @@ -1,32 +1,65 @@ from collections import Counter from typing import Iterable -from comments.models import KeyFactor -from comments.services.key_factors import get_votes_for_key_factors +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from comments.models import KeyFactor, KeyFactorDriver, ImpactDirection, KeyFactorVote +from comments.services.key_factors import ( + get_votes_for_key_factors, + calculate_key_factors_freshness, +) +from questions.models import Question from users.models import User from users.serializers import BaseUserSerializer -def serialize_key_factor_votes(key_factor: KeyFactor, vote_scores: list[int]): - pivot_votes = Counter(vote_scores) +def serialize_key_factor_votes( + key_factor: KeyFactor, vote_scores: list[KeyFactorVote], user_vote: int = None +): + pivot_votes = Counter([v.score for v in vote_scores]) return { "score": key_factor.votes_score, "aggregated_data": [ {"score": score, "count": count} for score, count in pivot_votes.items() ], - "user_vote": key_factor.user_vote, + "user_vote": user_vote, + "count": len(vote_scores), } -def serialize_key_factor(key_factor: KeyFactor, vote_scores: list[int] = None) -> dict: +def serialize_key_factor( + key_factor: KeyFactor, + vote_scores: list[KeyFactorVote] = None, + freshness: float = None, + question: Question = None, +) -> dict: return { "id": key_factor.id, - "driver": {"text": key_factor.driver.text} if key_factor.driver else None, "author": BaseUserSerializer(key_factor.comment.author).data, "comment_id": key_factor.comment_id, "post_id": key_factor.comment.on_post_id, - "vote": serialize_key_factor_votes(key_factor, vote_scores or []), + "vote": serialize_key_factor_votes( + key_factor, vote_scores or [], user_vote=key_factor.user_vote + ), + "question_id": key_factor.question_id, + "question": ( + { + "id": question.id, + "label": question.label, + } + if question + else None + ), + "question_option": key_factor.question_option, + "freshness": freshness, + # Type-specific fields + "driver": ( + KeyFactorDriverSerializer(key_factor.driver).data + if key_factor.driver + else None + ), } @@ -38,7 +71,9 @@ def serialize_key_factors_many( qs = ( KeyFactor.objects.filter(pk__in=ids) .filter_active() - .select_related("comment__author", "driver") + .select_related( + "comment__author", "comment__on_post", "question", "driver", "question" + ) ) if current_user: @@ -51,7 +86,47 @@ def serialize_key_factors_many( # Extract user votes votes_map = get_votes_for_key_factors(key_factors) + # Generate freshness + freshness_map = calculate_key_factors_freshness(key_factors, votes_map) + return [ - serialize_key_factor(key_factor, vote_scores=votes_map.get(key_factor.id)) + serialize_key_factor( + key_factor, + vote_scores=votes_map.get(key_factor.id), + freshness=freshness_map.get(key_factor), + question=key_factor.question, + ) for key_factor in objects ] + + +class KeyFactorDriverSerializer(serializers.ModelSerializer): + text = serializers.CharField(max_length=150) + impact_direction = serializers.ChoiceField(choices=ImpactDirection.choices) + + class Meta: + model = KeyFactorDriver + fields = ("text", "impact_direction", "certainty") + + +class KeyFactorWriteSerializer(serializers.ModelSerializer): + driver = KeyFactorDriverSerializer(required=False) + question_id = serializers.IntegerField(required=False) + + class Meta: + model = KeyFactor + fields = ( + "question_id", + "question_option", + "driver", + ) + + def validate(self, attrs: dict): + key_factor_types = ["driver"] + + if len([True for kf_type in key_factor_types if attrs.get(kf_type)]) != 1: + raise ValidationError( + "Key Factor should have exactly one type-specific object" + ) + + return attrs diff --git a/comments/services/key_factors.py b/comments/services/key_factors.py index f7751bab85..4b1866394e 100644 --- a/comments/services/key_factors.py +++ b/comments/services/key_factors.py @@ -2,11 +2,23 @@ from typing import Iterable from django.db import transaction +from django.utils import timezone from rest_framework.exceptions import ValidationError +from rest_framework.generics import get_object_or_404 -from comments.models import KeyFactor, KeyFactorVote, Comment, KeyFactorDriver +from comments.models import ( + KeyFactor, + KeyFactorVote, + Comment, + KeyFactorDriver, + ImpactDirection, +) from posts.models import Post +from posts.services.common import get_post_permission_for_user +from projects.permissions import ObjectPermission +from questions.models import Question from users.models import User +from utils.datetime import timedelta_to_days from utils.openai import generate_keyfactors @@ -36,24 +48,24 @@ def key_factor_vote( return key_factor.votes_score -def get_votes_for_key_factors(key_factors: Iterable[KeyFactor]) -> dict[int, list[int]]: +def get_votes_for_key_factors( + key_factors: Iterable[KeyFactor], +) -> dict[int, list[KeyFactorVote]]: """ Generates map of user votes for a set of KeyFactors """ - votes = KeyFactorVote.objects.filter(key_factor__in=key_factors).only( - "key_factor_id", "score" - ) + votes = KeyFactorVote.objects.filter(key_factor__in=key_factors) votes_map = defaultdict(list) for vote in votes: - votes_map[vote.key_factor_id].append(vote.score) + votes_map[vote.key_factor_id].append(vote) return votes_map @transaction.atomic -def create_key_factors(comment: Comment, key_factors: list[str]): +def create_key_factors(comment: Comment, key_factors: list[dict]): # Limit total key-factors for one user per comment if comment.key_factors.filter_active().count() + len(key_factors) > 4: raise ValidationError( @@ -73,9 +85,12 @@ def create_key_factors(comment: Comment, key_factors: list[str]): "Exceeded the maximum limit of 6 key factors allowed per question" ) - for key_factor in key_factors: - driver = KeyFactorDriver.objects.create(text=key_factor) - KeyFactor.objects.create(comment=comment, driver=driver) + for key_factor_data in key_factors: + create_key_factor( + user=comment.author, + comment=comment, + **key_factor_data, + ) def generate_keyfactors_for_comment( @@ -102,9 +117,144 @@ def generate_keyfactors_for_comment( ) +@transaction.atomic +def create_key_factor( + *, + user: User = None, + comment: Comment = None, + question_id: int = None, + question_option: str = None, + driver: dict = None, + **kwargs, +) -> KeyFactor: + question = None + + # Validate question + if question_id: + question = get_object_or_404(Question, pk=question_id) + + # Check permissions + permission = get_post_permission_for_user(question.get_post(), user=user) + ObjectPermission.can_view(permission, raise_exception=True) + + if question_option: + if not question: + raise ValidationError( + {"question_option": "Question ID is required for options"} + ) + + if question.type != Question.QuestionType.MULTIPLE_CHOICE: + raise ValidationError( + {"question_option": "Should be a multiple-choice question"} + ) + + if question_option not in question.options: + raise ValidationError( + {"question_option": "Question option must be one of the options"} + ) + + obj = KeyFactor( + comment=comment, + question_id=question_id, + question_option=question_option or "", + # Initial strength will be always 2 + votes_score=2, + **kwargs, + ) + + # Adding types + if driver: + obj.driver = create_key_factor_driver(**driver) + else: + raise ValidationError("Wrong Key Factor Type") + + # Save object and validate + obj.full_clean() + obj.save() + + return obj + + +def create_key_factor_driver( + *, + text: str = None, + impact_direction: ImpactDirection = None, + certainty: int = None, + **kwargs, +) -> KeyFactorDriver: + obj = KeyFactorDriver( + text=text, impact_direction=impact_direction, certainty=certainty, **kwargs + ) + obj.full_clean() + obj.save() + + return obj + + 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 delete_key_factor(key_factor: KeyFactor): + # TODO: should it delete a comment if that comment was automatically created? + + key_factor.delete() + + +def get_key_factor_question_lifetime(key_factor: KeyFactor) -> float: + post = key_factor.comment.on_post + question = key_factor.question + + open_time = post.open_time + + if question: + open_time = question.open_time + + if not open_time: + return 0.0 + + lifetime = timedelta_to_days(timezone.now() - open_time) + + return lifetime if lifetime > 0 else 0.0 + + +def calculate_freshness_driver( + key_factor: KeyFactor, votes: list[KeyFactorVote] +) -> float: + now = timezone.now() + lifetime = get_key_factor_question_lifetime(key_factor) + + weights_sum = 0 + strengths_sum = 0 + + for vote in votes: + weight = 2 ** ( + -timedelta_to_days(now - vote.created_at) / max(lifetime / 5, 14) + ) + weights_sum += weight + strengths_sum += vote.score * weight + + return (strengths_sum + 2 * max(3 - weights_sum, 0)) / max(weights_sum, 3) + + +def calculate_freshness(key_factor: KeyFactor, votes: list[KeyFactorVote]) -> float: + if key_factor.driver_id: + return calculate_freshness_driver(key_factor, votes) + + raise ValidationError("Key Factor does not support freshness calculation") + + +def calculate_key_factors_freshness( + key_factors: Iterable[KeyFactor], votes_map: dict[int, list[KeyFactorVote]] +) -> dict[KeyFactor, float]: + """ + Generates freshness of KeyFactors + """ + + return { + kf: calculate_freshness(kf, votes_map.get(kf.id) or []) for kf in key_factors + } diff --git a/comments/urls.py b/comments/urls.py index 97ec577b1f..aebb8ad769 100644 --- a/comments/urls.py +++ b/comments/urls.py @@ -43,6 +43,11 @@ key_factors.key_factor_vote_view, name="key-factor-vote", ), + path( + "key-factors//delete/", + key_factors.key_factor_delete, + name="key-factor-delete", + ), path( "comments//add-key-factors/", key_factors.comment_add_key_factors_view, diff --git a/comments/views/common.py b/comments/views/common.py index 1699e2b666..f14bb16244 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -23,6 +23,7 @@ CommentFilterSerializer, serialize_comments_of_the_week_many, ) +from comments.serializers.key_factors import KeyFactorWriteSerializer from comments.services.common import ( set_comment_excluded_from_week_top, create_comment, @@ -130,9 +131,9 @@ def comment_create_api_view(request: Request): parent = serializer.validated_data.get("parent") included_forecast = serializer.validated_data.pop("included_forecast", False) - key_factors = serializers.ListField( - child=serializers.CharField(allow_blank=False), allow_null=True - ).run_validation(request.data.get("key_factors")) + key_factors = KeyFactorWriteSerializer(allow_null=True, many=True).run_validation( + request.data.get("key_factors") + ) # Small validation permission = get_post_permission_for_user( diff --git a/comments/views/key_factors.py b/comments/views/key_factors.py index afc2ca3453..05a0c4c826 100644 --- a/comments/views/key_factors.py +++ b/comments/views/key_factors.py @@ -13,18 +13,25 @@ KeyFactorVote, ) from comments.serializers.common import serialize_comment_many +from comments.serializers.key_factors import ( + KeyFactorWriteSerializer, + serialize_key_factor_votes, +) from comments.services.key_factors import ( create_key_factors, generate_keyfactors_for_comment, key_factor_vote, + delete_key_factor, ) +from posts.services.common import get_post_permission_for_user +from projects.permissions import ObjectPermission @api_view(["POST"]) def key_factor_vote_view(request: Request, pk: int): key_factor = get_object_or_404(KeyFactor, pk=pk) vote = serializers.ChoiceField( - required=False, allow_null=True, choices=KeyFactorVote.VoteScore.choices + required=False, allow_null=True, choices=KeyFactorVote.VoteStrength.choices ).run_validation(request.data.get("vote")) # vote_type is always required, and when vote is None, the type is being used to # decide which vote to delete based on the type @@ -32,11 +39,13 @@ def key_factor_vote_view(request: Request, pk: int): required=True, allow_null=False, choices=KeyFactorVote.VoteType.choices ).run_validation(request.data.get("vote_type")) - score = key_factor_vote( - key_factor, user=request.user, vote=vote, vote_type=vote_type - ) + key_factor_vote(key_factor, user=request.user, vote=vote, vote_type=vote_type) - return Response({"score": score}) + return Response( + serialize_key_factor_votes( + key_factor, list(key_factor.votes.all()), user_vote=vote + ) + ) @api_view(["POST"]) @@ -49,11 +58,10 @@ def comment_add_key_factors_view(request: Request, pk: int): "You do not have permission to add key factors to this comment." ) - key_factors = serializers.ListField( - child=serializers.CharField(allow_blank=False), allow_null=True - ).run_validation(request.data.get("key_factors")) + serializer = KeyFactorWriteSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) - create_key_factors(comment, key_factors) + create_key_factors(comment, serializer.validated_data) return Response( serialize_comment_many([comment], with_key_factors=True)[0], @@ -84,3 +92,20 @@ def comment_suggested_key_factors_view(request: Request, pk: int): suggested_key_factors, status=status.HTTP_200_OK, ) + + +@api_view(["DELETE"]) +def key_factor_delete(request: Request, pk: int): + key_factor = get_object_or_404(KeyFactor, pk=pk) + + # Check access + permission = ( + ObjectPermission.CREATOR + if key_factor.comment.author_id == request.user.id + else get_post_permission_for_user(key_factor.comment.on_post, user=request.user) + ) + ObjectPermission.can_delete_key_factor(permission, raise_exception=True) + + delete_key_factor(key_factor) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/projects/permissions.py b/projects/permissions.py index 6fccbc6251..412a3f2e07 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -74,6 +74,20 @@ def can_comment(cls, permission: Self, raise_exception=False): return can + @classmethod + def can_delete_key_factor(cls, permission: Self, raise_exception=False): + can = permission in ( + cls.ADMIN, + cls.CREATOR, + ) + + if raise_exception and not can: + raise PermissionDenied( + "You do not have permission to delete this Key Factor" + ) + + return can + @classmethod def can_pin_comment(cls, permission: Self, raise_exception=False): can = permission in (cls.ADMIN,) diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index 9d6b864987..f8cc1f8005 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -1,9 +1,14 @@ import pytest # noqa +from freezegun import freeze_time from rest_framework.exceptions import ValidationError from comments.models import KeyFactorVote, KeyFactorDriver from comments.services.common import create_comment, soft_delete_comment -from comments.services.key_factors import key_factor_vote, create_key_factors +from comments.services.key_factors import ( + key_factor_vote, + create_key_factors, + calculate_freshness_driver, +) from comments.services.notifications import notify_mentioned_users from posts.models import Post, PostUserSnapshot from projects.permissions import ObjectPermission @@ -13,6 +18,7 @@ from tests.unit.test_questions.conftest import * # noqa from tests.unit.test_questions.factories import factory_forecast from tests.unit.test_users.factories import factory_user +from tests.unit.utils import datetime_aware @pytest.fixture() @@ -183,21 +189,92 @@ def test_soft_delete_comment(user1, user2, post): def test_create_key_factors__limit_validation(user1, user2, post): c1 = factory_comment(author=user1, on_post=post) c2 = factory_comment(author=user1, on_post=post) - create_key_factors(c1, ["1", "2", "3"]) + create_key_factors( + c1, + [ + {"driver": {"text": "1", "impact_direction": -1}}, + {"driver": {"text": "2", "impact_direction": 1}}, + {"driver": {"text": "3", "impact_direction": -1}}, + ], + ) assert c1.key_factors.count() == 3 # Create too many key-factors for one comment with pytest.raises(ValidationError): - create_key_factors(c1, ["4", "5"]) + create_key_factors( + c1, + [ + {"driver": {"text": "4", "impact_direction": 1}}, + {"driver": {"text": "5", "impact_direction": -1}}, + ], + ) - create_key_factors(c2, ["2.1", "2.2", "2.3"]) + create_key_factors( + c2, + [ + {"driver": {"text": "2.1", "impact_direction": 1}}, + {"driver": {"text": "2.2", "impact_direction": -1}}, + {"driver": {"text": "2.3", "impact_direction": 1}}, + ], + ) assert c2.key_factors.count() == 3 # Create too many key-factors for one post with pytest.raises(ValidationError): - create_key_factors(c2, ["2.4"]) + create_key_factors(c2, [{"driver": {"text": "2.4", "impact_direction": -1}}]) # Check limit does not affect other users c3 = factory_comment(author=user2, on_post=post) - create_key_factors(c3, ["3.1"]) + create_key_factors(c3, [{"driver": {"text": "3.1", "impact_direction": 1}}]) + + +@freeze_time("2025-09-30") +def test_calculate_freshness_driver(user1, post): + # Lifetime: 100 days + post.open_time = datetime_aware(2025, 6, 22) + post.save() + + kf = factory_key_factor( + comment=factory_comment(author=user1, on_post=post), + driver=KeyFactorDriver.objects.create(text="Driver"), + ) + + # 2**(- (now - vote.created_at) / max(question_lifetime / 5, two_weeks)) + + # 1d ago + # 2 ** (-1 / max(100 / 5, 14)) == 0.967 + with freeze_time("2025-09-29"): + KeyFactorVote.objects.create( + key_factor=kf, + score=KeyFactorVote.VoteStrength.LOW_STRENGTH, + user=factory_user(), + vote_type=KeyFactorVote.VoteType.STRENGTH, + ) + + # 1w ago + # 2 ** (-7 / max(100 / 5, 14)) == 0.785 + with freeze_time("2025-09-23"): + KeyFactorVote.objects.create( + key_factor=kf, + score=KeyFactorVote.VoteStrength.HIGH_STRENGTH, + user=factory_user(), + vote_type=KeyFactorVote.VoteType.STRENGTH, + ) + + # 2w ago + # 2 ** (-14 / max(100 / 5, 14)) == 0.616 + with freeze_time("2025-09-16"): + KeyFactorVote.objects.create( + key_factor=kf, + score=KeyFactorVote.VoteStrength.MEDIUM_STRENGTH, + user=factory_user(), + vote_type=KeyFactorVote.VoteType.STRENGTH, + ) + + # ((0.967 * 1 + 0.785 * 5 + 0.616 * 2) + 2 * max(0, 3 - (0.967 + 0.785 + 0.616))) + # / max((0.967 + 0.785 + 0.616), 3) + + assert calculate_freshness_driver(kf, list(kf.votes.all())) == pytest.approx( + 2.463, abs=0.001 + ) diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index 9d5c46e381..e2c17fac8b 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -1,14 +1,14 @@ import pytest # noqa from django.urls import reverse -from comments.models import Comment +from comments.models import Comment, ImpactDirection from comments.services.feed import get_comments_feed from questions.services import create_forecast from tests.unit.test_comments.factories import factory_comment from tests.unit.test_posts.factories import factory_post from tests.unit.test_projects.factories import factory_project -from tests.unit.test_questions.factories import factory_group_of_questions from tests.unit.test_questions.conftest import * # noqa +from tests.unit.test_questions.factories import factory_group_of_questions class TestPagination: @@ -291,3 +291,31 @@ def test_with_forecast__group_questions( {"on_post": post.pk, "text": "Test comment", "included_forecast": True}, ) assert response.status_code == 201 + + def test_create_with_key_factor(self, user1_client, post, question_binary): + response = user1_client.post( + self.url, + { + "on_post": post.pk, + "text": "Comment with Key Factors", + "key_factors": [ + { + "question_id": question_binary.pk, + "driver": { + "text": "Key Factor Driver", + "impact_direction": -1, + }, + } + ], + }, + format="json", + ) + + assert response.status_code == 201 + + assert response.data["on_post"] == post.pk + assert response.data["text"] == "Comment with Key Factors" + kf1 = response.data["key_factors"][0] + assert kf1["question_id"] == question_binary.pk + assert kf1["driver"]["text"] == "Key Factor Driver" + assert kf1["driver"]["impact_direction"] == -1 diff --git a/utils/datetime.py b/utils/datetime.py new file mode 100644 index 0000000000..d0d0105194 --- /dev/null +++ b/utils/datetime.py @@ -0,0 +1,15 @@ +from datetime import timedelta + + +def timedelta_to_days(td: timedelta) -> float: + """ + Convert a timedelta to a float representing total days (including fractions). + + Args: + td (timedelta): The timedelta to convert. + + Returns: + float: Total days as a float. + """ + + return td.total_seconds() / 86400.0 From 4a005e95613bd67676bdc5725f71502bacce52f2 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 17 Oct 2025 17:59:14 +0200 Subject: [PATCH 17/23] Small fix --- tests/unit/test_comments/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index e2c17fac8b..b7c98491d3 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -1,7 +1,7 @@ import pytest # noqa from django.urls import reverse -from comments.models import Comment, ImpactDirection +from comments.models import Comment from comments.services.feed import get_comments_feed from questions.services import create_forecast from tests.unit.test_comments.factories import factory_comment From 0c9fbf6deb582faf9864afde4d5d6993350ebc40 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 22 Oct 2025 12:47:06 +0200 Subject: [PATCH 18/23] Fixed Driver creation validation --- comments/serializers/key_factors.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/comments/serializers/key_factors.py b/comments/serializers/key_factors.py index 877e33856a..5bdf814665 100644 --- a/comments/serializers/key_factors.py +++ b/comments/serializers/key_factors.py @@ -102,12 +102,22 @@ def serialize_key_factors_many( class KeyFactorDriverSerializer(serializers.ModelSerializer): text = serializers.CharField(max_length=150) - impact_direction = serializers.ChoiceField(choices=ImpactDirection.choices) + impact_direction = serializers.ChoiceField( + choices=ImpactDirection.choices, allow_null=True + ) class Meta: model = KeyFactorDriver fields = ("text", "impact_direction", "certainty") + def validate(self, attrs): + if bool(attrs.get("impact_direction")) == bool(attrs.get("certainty")): + raise serializers.ValidationError( + "Impact Direction or Certainty is required" + ) + + return attrs + class KeyFactorWriteSerializer(serializers.ModelSerializer): driver = KeyFactorDriverSerializer(required=False) From 52be0f9f522e5fe615c618a947989493cc6f011a Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 22 Oct 2025 17:39:48 +0200 Subject: [PATCH 19/23] Small fix --- comments/serializers/key_factors.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/comments/serializers/key_factors.py b/comments/serializers/key_factors.py index 5bdf814665..fd096eb61f 100644 --- a/comments/serializers/key_factors.py +++ b/comments/serializers/key_factors.py @@ -48,6 +48,7 @@ def serialize_key_factor( { "id": question.id, "label": question.label, + "unit": question.unit, } if question else None @@ -71,9 +72,7 @@ def serialize_key_factors_many( qs = ( KeyFactor.objects.filter(pk__in=ids) .filter_active() - .select_related( - "comment__author", "comment__on_post", "question", "driver", "question" - ) + .select_related("comment__author", "comment__on_post", "question", "driver") ) if current_user: From 00c6fb7be98a72f538018c354de18f6d3c3ecbc9 Mon Sep 17 00:00:00 2001 From: Hlib Date: Thu, 23 Oct 2025 16:22:30 +0200 Subject: [PATCH 20/23] Key Factors V3 Frontend (#3626) * Refactored KeyFactor creation endpoints and created extra services * Small fix * Removed extra todo * Implemented KeyFactor deletion endpoint * utils/the_math/aggregations.py * Small fix * Small fix * Basic refactoring of KeyFactors * Deprecated old A/B testing components * Small adjustments * Small fix * Adjusted keyFactor vote view * Added Impact Renderer component * Added Impact Renderer component * Small fix * Small fix * Added Segmented Progress bar to support smooth animations * Added KeyFactor theme * Small adjustments * Set default key factor strength to 1 * Added Direction Impact subquestion/option labels * Backend: return question label object * Small fix * Added Driver.certainty * Add Key Factor Restyle * Small fix * feat: add submit support * KeyFactor Driver Updates (#3632) * Driver updates * Adjusted KeyFactors Appearance: - Added isCompact mode - Added Consumer view mode * Implemented basic KeyFactorsCarousel * Small fixes * Small fixes * Small fix * Small adjustment * Small fix * Voting: optimistic updates * Small fix * Small renaming * Small renaming * Added KeyFactorsConsumer section * Added translations * Adjusted carousels * Added edges-overflow for consumer carousel * Adjusted QuestionTimeline rendering for consumer views * feat: add option select * Feat/key factors modal adjustments (#3659) * KeyFactors creation modal adjustments * Momorize MDXeditor * Small fix * KF modal validation * Small fixes * Small fix * - Added KeyFactors Context Provider - Adjusted key factors scrolling mechanism * Small fix * Small fix * Small fix * Small fix * Fixed dark theme likehood button * Fixed Hydration * - Fixed certainty label - Fixed gap * - Refactored drivers creation services - Fixed option selection - Added "All Options" option * Removed auto-selection of impact direction during KF creation * Added KeyFactor card units * Tiny fix * Fixed modal height * Fixed carousel gradients * Made comment editor font size 16px on mobile * - Added Suggested KF autoscroll - Adjusted mobile view --------- Co-authored-by: Nikita --- front_end/messages/cs.json | 32 +- front_end/messages/en.json | 32 +- front_end/messages/es.json | 28 +- front_end/messages/pt.json | 28 +- front_end/messages/zh-TW.json | 26 +- front_end/messages/zh.json | 26 +- .../components/comments_feed_provider.tsx | 40 +-- .../[id]/[[...slug]]/page_component.tsx | 33 +- .../key_factors/add_key_factors_modal.tsx | 293 ++++++++--------- .../add_modal/driver_creation_form.tsx | 138 ++++++++ .../add_modal/impact_direction_controls.tsx | 130 ++++++++ .../[id]/components/key_factors/hooks.ts | 89 ++++-- .../key_factors/key_factor_item/index.tsx | 83 +++-- .../key_factor_item/key_factor_driver.tsx | 88 +++++ .../key_factor_item/key_factor_header.tsx | 52 +++ .../key_factor_strength_voter.tsx | 180 +++++++++++ .../key_factor_item/key_factor_text.tsx | 43 +-- .../key_factor_item/likert_item.tsx | 266 --------------- .../segmented_progress_bar.tsx | 91 ++++++ .../key_factor_item/two_step_item.tsx | 302 ------------------ .../key_factor_item/updown_item.tsx | 61 ---- .../key_factors/key_factor_voter.tsx | 96 ------ .../key_factors/key_factors_carousel.tsx | 52 +++ .../key_factors_comment_section.tsx | 63 ++++ .../key_factors_consumer_section.tsx | 48 +++ .../key_factors_impact_direction.tsx | 163 ++++++++++ .../key_factors/key_factors_provider.tsx | 59 ++++ .../key_factors/key_factors_section.tsx | 132 +++----- .../key_factors/likehood_button.tsx | 65 ++++ .../key_factors/option_target_picker.tsx | 124 +++++++ .../consumer_question_layout/index.tsx | 8 + .../forecaster_question_layout/index.tsx | 2 + .../question_layout/question_info.tsx | 16 +- .../consumer_question_view/index.tsx | 23 +- front_end/src/app/(main)/questions/actions.ts | 3 +- .../link_strength_component.tsx | 8 +- front_end/src/components/base_modal.tsx | 2 +- .../src/components/comment_feed/comment.tsx | 69 ++-- .../comment_feed/comment_editor.tsx | 1 + .../src/components/gradient-carousel.tsx | 142 ++++---- .../src/components/ui/expandable_content.tsx | 11 + front_end/src/components/ui/listbox.tsx | 284 ++++++++++++---- .../services/api/comments/comments.server.ts | 9 +- .../services/api/comments/comments.shared.ts | 9 +- front_end/src/types/comment.ts | 57 ++-- front_end/src/types/key_factors.ts | 13 + front_end/src/utils/questions/helpers.ts | 18 ++ 47 files changed, 2204 insertions(+), 1334 deletions(-) create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/impact_direction_controls.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_driver.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_header.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_strength_voter.tsx delete mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/likert_item.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/segmented_progress_bar.tsx delete mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/two_step_item.tsx delete mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/updown_item.tsx delete mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_voter.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_consumer_section.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_impact_direction.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_provider.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/likehood_button.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx create mode 100644 front_end/src/types/key_factors.ts diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 02db7e3b98..b31efcafab 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1169,8 +1169,8 @@ "back": "Zpět", "noKeyFactorsP1": "Žádné klíčové faktory zatím nejsou", "noKeyFactorsP2": "Přidejte některé, které by mohly ovlivnit tuto prognózu.", - "increasesLikelihood": "Zvyšuje pravděpodobnost", - "decreasesLikelihood": "Snižuje pravděpodobnost", + "increasesLikelihood": "Zvyšuje Pravděpodobnost", + "decreasesLikelihood": "Snižuje Pravděpodobnost", "howImpactfulFactor": "Jak výrazný si myslíte, že tento faktor je?", "lowImpact": "Nízký dopad", "moderateImpact": "Střední dopad", @@ -1181,7 +1181,7 @@ "howInfluenceForecast": "Jak to ovlivňuje vaši předpověď?", "decreases": "snižuje", "increases": "zvyšuje", - "noImpact": "bez dopadu", + "noImpact": "Bez dopadu", "maxKeyFactorsPerComment": "Komentář může obsahovat maximálně 4 klíčové faktory.", "maxKeyFactorsPerQuestion": "Otázka může mít od jednoho autora nejvýše 6 klíčových faktorů.", "maxKeyFactorLength": "Klíčové faktory musí mít méně než 150 znaků.", @@ -1508,9 +1508,9 @@ "unreadWithTotalCount": "({unread_count_formatted} nepřečtených) {total_count_formatted} celkem", "totalCommentsCount": "{total_count_formatted} {total_count, plural, =0 {komentářů} =1 {komentář} other {komentáře}}", "noParticipationProject": "Dosud jste se na tomto {projectType} neúčastnili.", - "low": "Nízká síla", - "medium": "Střední síla", - "high": "Vysoká síla", + "lowStrength": "Nízká síla", + "mediumStrength": "Střední síla", + "highStrength": "Vysoká síla", "thisQuestionCausesOtherQuestion": "Tato otázka má dopad na ", "otherQuestionCausesThisQuestion": " dopad na tuto otázku", "thisQuestionCausesOtherQuestionAdverbial": "Tato otázka ", @@ -1533,5 +1533,25 @@ "3years": "3 roky", "resetToDefault": "Obnovit výchozí nastavení", "useAccountSettingDescription": "podle vašeho výchozího nastavení účtu { userForecastExpirationPercent }% z celkové doby trvání otázky (s minimem 1 měsíc). Můžete to změnit ve svých nastaveních", + "votesWithCount": "{count} {count, plural, =0 {hlas} =1 {hlas} other {hlasy} }", + "strength": "Síla", + "increasesUncertainty": "Zvyšuje nejistotu", + "impact": "Dopad", + "byUsername": "od {username}", + "driver": "Řidič", + "forOption": "pro {option}", + "driverInputPlaceholder": "Zadejte svého řidiče zde", + "chooseDirectionOfImpact": "Vyberte směr nárazu:", + "less": "Méně", + "earlier": "Dříve", + "medium": "střední", + "topKeyFactors": "Hlavní klíčové faktory", + "addDriver": "Přidat řidiče", + "addDriverModalDescription": "Zadejte své řidiče níže - snažte se je udržet stručné a jasné.", + "addDriverModalCommentDescription": "Řidiče by měly být podloženy komentáři - zadejte níže svůj komentář a vysvětlete své důvody.", + "allOptions": "Všechny možnosti", + "allSubquestions": "Všechny podotázky", + "chooseOptionImpactedMost": "Vyberte možnost, kterou tento řidič ovlivňuje nejvíce:", + "chooseSubquestionImpactedMost": "Vyberte podotázku, kterou tento řidič ovlivňuje nejvíce:", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 766e7404b3..14ab2da7cf 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1111,6 +1111,7 @@ "finishRegistration": "Finish Registration", "keyFactor": "Key Factor", "keyFactors": "Key Factors", + "topKeyFactors": "Top Key Factors", "notAvailable": "Not available", "indexesTitle": "Project", "indexScore": "Index value: {value}", @@ -1254,26 +1255,39 @@ "postNotebookMoveDateModalCopy": "The tournament forecasting end date is {tournament_forecasting_end_date} and you are trying to approve a question that closes on {question_close_date}. Do you want to move the tournament forecasting end date to {question_close_date}?", "moveDate": "Move date", "addKeyFactor": "Add key factor", + "addDriver": "Add Driver", "addKeyFactors": "Add Key Factors", "addKeyFactorsModalP1": "List the key factors you think matter most — keep each one short and specific.", + "addDriverModalDescription": "Enter your drivers below - try to keep them short and clear.", + "addDriverModalCommentDescription": "Drivers should be backed up by comments - enter a comment below and explain your reasoning.", "addKeyFactorsModalP2": "Please post a comment to explain why these factors matter for your forecast.", "typeKeyFator": "Type a key factor here", + "driverInputPlaceholder": "Type your driver here", + "chooseDirectionOfImpact": "Choose direction of impact:", + "less": "Less", + "earlier": "Earlier", "back": "Back", "noKeyFactorsP1": "No key factors yet", "noKeyFactorsP2": "Add some that might influence this forecast.", - "increasesLikelihood": "Increases likelihood", - "decreasesLikelihood": "Decreases likelihood", + "increasesLikelihood": "Increases Likelihood", + "decreasesLikelihood": "Decreases Likelihood", + "increasesUncertainty": "Increases Uncertainty", + "impact": "Impact", "howImpactfulFactor": "How impactful do you think this factor is?", "lowImpact": "Low impact", "moderateImpact": "Moderate impact", "highImpact": "High impact", "thankYouForSubmission": "Thank you for your submission!", + "byUsername": "by {username}", + "driver": "Driver", + "forOption": "for {option}", "vote": "Vote", "voted": "Voted", + "votesWithCount": "{count} {count, plural, =0 {vote} =1 {vote} other {votes} }", "howInfluenceForecast": "How does this influence your forecast?", "decreases": "decreases", "increases": "increases", - "noImpact": "no impact", + "noImpact": "No impact", "maxKeyFactorsPerComment": "A comment can include a maximum of 4 key factors.", "maxKeyFactorsPerQuestion": "A question can have no more than 6 key factors by one author.", "maxKeyFactorLength": "Key factors must be less than 150 characters.", @@ -1506,9 +1520,10 @@ "inNews": "In the News", "info": "Question Info", "noParticipationProject": "You have not participated in this {projectType} yet.", - "low": "Low strength", - "medium": "Medium strength", - "high": "High strength", + "strength": "Strength", + "lowStrength": "Low strength", + "mediumStrength": "Medium strength", + "highStrength": "High strength", "thisQuestionCausesOtherQuestion": "This question has a impact on ", "otherQuestionCausesThisQuestion": " has a impact on this question", "thisQuestionCausesOtherQuestionAdverbial": "This question ", @@ -1526,6 +1541,11 @@ "hiddenUntil": "Hidden until", "certainty": "Certainty:", "strong": "strong", + "medium": "medium", "weak": "weak", + "allOptions": "All options", + "allSubquestions": "All subquestions", + "chooseOptionImpactedMost": "Choose an option this driver impacts most:", + "chooseSubquestionImpactedMost": "Choose a subquestion this driver impacts most:", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index d84228a2eb..17b7c0deec 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1181,7 +1181,7 @@ "howInfluenceForecast": "¿Cómo influye esto en tu pronóstico?", "decreases": "disminuye", "increases": "aumenta", - "noImpact": "sin impacto", + "noImpact": "Sin impacto", "maxKeyFactorsPerComment": "Un comentario puede incluir un máximo de 4 factores clave.", "maxKeyFactorsPerQuestion": "Una pregunta puede tener no más de 6 factores clave por un autor.", "maxKeyFactorLength": "Los factores clave deben tener menos de 150 caracteres.", @@ -1508,9 +1508,9 @@ "unreadWithTotalCount": "({unread_count_formatted} sin leer) {total_count_formatted} en total", "totalCommentsCount": "{total_count_formatted} {total_count, plural, =0 {comentarios} =1 {comentario} other {comentarios}}", "noParticipationProject": "Todavía no has participado en este {projectType}.", - "low": "Baja resistencia", - "medium": "Resistencia media", - "high": "Alta resistencia", + "lowStrength": "Baja resistencia", + "mediumStrength": "Resistencia media", + "highStrength": "Alta resistencia", "thisQuestionCausesOtherQuestion": "Esta pregunta tiene un impacto en ", "otherQuestionCausesThisQuestion": " tiene un impacto en esta pregunta", "thisQuestionCausesOtherQuestionAdverbial": "Esta pregunta ", @@ -1533,5 +1533,25 @@ "3years": "3 años", "resetToDefault": "Restablecer a valores predeterminados", "useAccountSettingDescription": "basado en el valor predeterminado de tu cuenta del { userForecastExpirationPercent }% de la duración total de la pregunta (con un mínimo de 1 mes). Puedes cambiar esto en tus configuración", + "votesWithCount": "{count} {count, plural, =0 {voto} =1 {voto} other {votos} }", + "strength": "Fuerza", + "increasesUncertainty": "Aumenta la Incertidumbre", + "impact": "Impacto", + "byUsername": "por {username}", + "driver": "Conductor", + "forOption": "para {option}", + "driverInputPlaceholder": "Escribe tu conductor aquí", + "chooseDirectionOfImpact": "Elige la dirección del impacto:", + "less": "Menos", + "earlier": "Antes", + "medium": "medio", + "topKeyFactors": "Factores Clave Principales", + "addDriver": "Agregar conductor", + "addDriverModalDescription": "Introduce tus conductores a continuación - trata de mantenerlos breves y claros.", + "addDriverModalCommentDescription": "Los conductores deben estar respaldados por comentarios - introduce un comentario a continuación y explica tu razonamiento.", + "allOptions": "Todas las opciones", + "allSubquestions": "Todas las subpreguntas", + "chooseOptionImpactedMost": "Elige una opción que este conductor impacte más:", + "chooseSubquestionImpactedMost": "Elige una subpregunta que este conductor impacte más:", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index fe3a857d88..08b69865ad 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1179,7 +1179,7 @@ "howInfluenceForecast": "Como isso influencia sua previsão?", "decreases": "diminui", "increases": "aumenta", - "noImpact": "sem impacto", + "noImpact": "Sem impacto", "maxKeyFactorsPerComment": "Um comentário pode incluir no máximo 4 fatores-chave.", "maxKeyFactorsPerQuestion": "Uma pergunta pode ter no máximo 6 fatores-chave por um autor.", "maxKeyFactorLength": "Os fatores principais devem ter menos de 150 caracteres.", @@ -1506,9 +1506,9 @@ "unreadWithTotalCount": "({unread_count_formatted} não lida) {total_count_formatted} no total", "totalCommentsCount": "{total_count_formatted} {total_count, plural, =0 {comentários} =1 {comentário} other {comentários}}", "noParticipationProject": "Você ainda não participou deste {projectType}.", - "low": "Força baixa", - "medium": "Força média", - "high": "Força alta", + "lowStrength": "Força baixa", + "mediumStrength": "Força média", + "highStrength": "Força alta", "thisQuestionCausesOtherQuestion": "Esta pergunta tem um impacto sobre ", "otherQuestionCausesThisQuestion": " tem um impacto nesta pergunta", "thisQuestionCausesOtherQuestionAdverbial": "Esta pergunta ", @@ -1531,5 +1531,25 @@ "3years": "3 anos", "resetToDefault": "Redefinir para o padrão", "useAccountSettingDescription": "com base no padrão da sua conta de { userForecastExpirationPercent }% da duração total da pergunta (com um mínimo de 1 mês). Você pode alterar isso em suas configurações", + "votesWithCount": "{count} {count, plural, =0 {voto} =1 {voto} other {votos} }", + "strength": "Força", + "increasesUncertainty": "Aumenta a Incerteza", + "impact": "Impacto", + "byUsername": "por {username}", + "driver": "Condutor", + "forOption": "para {option}", + "driverInputPlaceholder": "Digite seu driver aqui", + "chooseDirectionOfImpact": "Escolha a direção do impacto:", + "less": "Menos", + "earlier": "Mais cedo", + "medium": "médio", + "topKeyFactors": "Principais Fatores Chave", + "addDriver": "Adicionar Driver", + "addDriverModalDescription": "Digite seus drivers abaixo - tente mantê-los curtos e claros.", + "addDriverModalCommentDescription": "Os drivers devem ser apoiados por comentários - insira um comentário abaixo e explique seu raciocínio.", + "allOptions": "Todas as opções", + "allSubquestions": "Todas as subquestões", + "chooseOptionImpactedMost": "Escolha uma opção que este driver impacta mais:", + "chooseSubquestionImpactedMost": "Escolha uma subquestão que este driver impacta mais:", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index eaeb1180bf..cdd2c700da 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1505,9 +1505,9 @@ "baseline": "基準線", "noParticipationProject": "您尚未參與此 {projectType}。", "toggledTimelines": "切換時間軸", - "low": "低強度", - "medium": "中強度", - "high": "高強度", + "lowStrength": "低強度", + "mediumStrength": "中強度", + "highStrength": "高強度", "thisQuestionCausesOtherQuestion": "這個問題對 產生 影響", "otherQuestionCausesThisQuestion": " 對這個問題產生 影響", "thisQuestionCausesOtherQuestionAdverbial": "這個問題 ", @@ -1530,5 +1530,25 @@ "3years": "3 年", "resetToDefault": "重設為預設值", "useAccountSettingDescription": "根據您帳戶的預設設置,即問題總時長的 { userForecastExpirationPercent }%(最短為 1 個月)。您可以在您的設置中更改此設置", + "votesWithCount": "{count} {count, plural, =0 {票} =1 {票} other {票} }", + "strength": "力量", + "increasesUncertainty": "增加不確定性", + "impact": "影響", + "byUsername": "由 {username} 提供", + "driver": "驅動程式", + "forOption": "對 {option}", + "driverInputPlaceholder": "在此輸入您的驅動因素", + "chooseDirectionOfImpact": "選擇影響的方向:", + "less": "較少", + "earlier": "較早", + "medium": "中等", + "topKeyFactors": "關鍵因素排名", + "addDriver": "新增驅動因素", + "addDriverModalDescription": "在下方輸入您的驅動因素 - 盡量保持簡短明瞭。", + "addDriverModalCommentDescription": "驅動因素應有評論支撐 - 在下方輸入評論並解釋您的理由。", + "allOptions": "所有選項", + "allSubquestions": "所有子問題", + "chooseOptionImpactedMost": "選擇此驅動因素影響最大的選項:", + "chooseSubquestionImpactedMost": "選擇此驅動因素影響最大的子問題:", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 039ca13217..172daf6698 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1510,9 +1510,9 @@ "unreadWithTotalCount": "({unread_count_formatted} 未读) {total_count_formatted} 总计", "totalCommentsCount": "{total_count_formatted} 条评论", "noParticipationProject": "您还没有参与过此{projectType}。", - "low": "低强度", - "medium": "中等强度", - "high": "高强度", + "lowStrength": "低强度", + "mediumStrength": "中等强度", + "highStrength": "高强度", "thisQuestionCausesOtherQuestion": "此问题对 影响", "otherQuestionCausesThisQuestion": " 对此问题有 影响", "thisQuestionCausesOtherQuestionAdverbial": "这个问题 ", @@ -1535,5 +1535,25 @@ "3years": "3 年", "resetToDefault": "重置为默认", "useAccountSettingDescription": "基于您的账户默认设置,占问题总时长的 { userForecastExpirationPercent }%(最低 1 个月)。您可以在 设置 中更改此项", + "votesWithCount": "{count} {count, plural, =0 {票} =1 {票} other {票} }", + "strength": "强度", + "increasesUncertainty": "增加不确定性", + "impact": "影响", + "byUsername": "由{username}发布", + "driver": "驾驶员", + "forOption": "对于{option}", + "driverInputPlaceholder": "在此输入您的驱动程序", + "chooseDirectionOfImpact": "选择影响方向:", + "less": "较少", + "earlier": "提前", + "medium": "中", + "topKeyFactors": "主要关键因素", + "addDriver": "添加驱动因素", + "addDriverModalDescription": "在下面输入您的驱动因素 - 尽量保持简短明了。", + "addDriverModalCommentDescription": "驱动因素应有评论支持 - 在下面输入评论并解释您的理由。", + "allOptions": "所有选项", + "allSubquestions": "所有子问题", + "chooseOptionImpactedMost": "选择一个受此驱动因素影响最大的选项:", + "chooseSubquestionImpactedMost": "选择一个受此驱动因素影响最大的子问题:", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/components/comments_feed_provider.tsx b/front_end/src/app/(main)/components/comments_feed_provider.tsx index 5c27207f2a..c56127408b 100644 --- a/front_end/src/app/(main)/components/comments_feed_provider.tsx +++ b/front_end/src/app/(main)/components/comments_feed_provider.tsx @@ -15,8 +15,7 @@ import { BECommentType, CommentType, KeyFactor, - KeyFactorVote, - KeyFactorVoteTypes, + KeyFactorVoteAggregate, } from "@/types/comment"; import { PostWithForecasts } from "@/types/post"; import { parseComment } from "@/utils/comments"; @@ -44,8 +43,7 @@ export type CommentsFeedContextType = { setCombinedKeyFactors: (combinedKeyFactors: KeyFactor[]) => void; setKeyFactorVote: ( keyFactorId: number, - keyFactorVote: KeyFactorVote, - votesScore: number + aggregate: KeyFactorVoteAggregate ) => void; }; @@ -113,72 +111,52 @@ const CommentsFeedProvider: FC< const [offset, setOffset] = useState(0); const initialKeyFactors = [...(postData?.key_factors ?? [])].sort((a, b) => - b.votes_score === a.votes_score + b.vote?.score === a.vote?.score ? Math.random() - 0.5 - : b.votes_score - a.votes_score + : (b.vote?.score || 0) - (a.vote?.score || 0) ); const [combinedKeyFactors, setCombinedKeyFactors] = useState(initialKeyFactors); const setAndSortCombinedKeyFactors = (keyFactors: KeyFactor[]) => { const sortedKeyFactors = [...keyFactors].sort( - (a, b) => b.votes_score - a.votes_score + (a, b) => (b.vote?.score || 0) - (a.vote?.score || 0) ); setCombinedKeyFactors(sortedKeyFactors); }; const setKeyFactorVote = ( keyFactorId: number, - keyFactorVote: KeyFactorVote, - votes_score: number + aggregate: KeyFactorVoteAggregate ) => { // Update the list of combined key factors with the new vote const newKeyFactors = combinedKeyFactors.map((kf) => kf.id === keyFactorId ? { ...kf, - votes_score, - user_votes: [ - ...kf.user_votes.filter( - (vote) => vote.vote_type !== keyFactorVote.vote_type - ), - keyFactorVote, - ], + vote: aggregate, } : { ...kf } ); - if (keyFactorVote.vote_type === KeyFactorVoteTypes.UP_DOWN) { - setAndSortCombinedKeyFactors(newKeyFactors); - } else { - setCombinedKeyFactors(newKeyFactors); - } + setCombinedKeyFactors(newKeyFactors); //Update the comments state with the new vote for the key factor setComments((prevComments) => { return prevComments.map((comment) => { - // Check if this comment has the key factor we're updating if (comment.key_factors?.some((kf) => kf.id === keyFactorId)) { - // Create a new comment object with updated key factors return { ...comment, key_factors: comment.key_factors?.map((kf) => kf.id === keyFactorId ? { ...kf, - votes_score, - user_votes: [ - ...kf.user_votes.filter( - (vote) => vote.vote_type !== keyFactorVote.vote_type - ), - keyFactorVote, - ], + vote: aggregate, } : kf ), }; } - // Return unchanged comment if it doesn't have the key factor return { ...comment }; }); }); diff --git a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx index 665c0cedf5..150b76fbe5 100644 --- a/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx +++ b/front_end/src/app/(main)/questions/[id]/[[...slug]]/page_component.tsx @@ -21,6 +21,7 @@ import QuestionView from "../components/question_view"; import Sidebar from "../components/sidebar"; import { SLUG_POST_SUB_QUESTION_ID } from "../search_params"; import { cachedGetPost } from "./utils/get_post"; +import { KeyFactorsProvider } from "../components/key_factors/key_factors_provider"; const CommunityDisclaimer = dynamic( () => import("@/components/post_card/community_disclaimer") @@ -78,22 +79,26 @@ const IndividualQuestionPage: FC<{ /> )} - - {isCommunityQuestion && ( - - )} - + - + > + {isCommunityQuestion && ( + + )} + + + diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx index eb5ab5671b..8c098114c7 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx @@ -1,19 +1,24 @@ "use client"; -import { faCircleXmark } from "@fortawesome/free-regular-svg-icons"; -import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { + faChevronRight, + faMinus, + faPlus, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import { FC, useState } from "react"; +import { FC, memo, useState } from "react"; +import DriverCreationForm from "@/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form"; import BaseModal from "@/components/base_modal"; import MarkdownEditor from "@/components/markdown_editor"; import Button from "@/components/ui/button"; -import { FormError, Input } from "@/components/ui/form_field"; +import { FormError } from "@/components/ui/form_field"; import LoadingSpinner from "@/components/ui/loading_spiner"; import { BECommentType } from "@/types/comment"; +import { KeyFactorDraft } from "@/types/key_factors"; +import { PostWithForecasts } from "@/types/post"; import { User } from "@/types/users"; -import { sendAnalyticsEvent } from "@/utils/analytics"; import { useKeyFactors } from "./hooks"; @@ -27,101 +32,44 @@ type Props = { commentId?: number; // Used when adding key factors and also creating a new comment on a given post // This also determines the number of steps in the modal: 2 if a new comment is createad too - postId?: number; + post: PostWithForecasts; // if true, loads a set of suggested key factors showSuggestedKeyFactors?: boolean; onSuccess?: (comment: BECommentType) => void; }; -const Step2AddComment = ({ - markdown, - setMarkdown, -}: { - markdown: string; - setMarkdown: (markdown: string) => void; -}) => { - const t = useTranslations(); - return ( -
-

{t("addKeyFactorsModalP2")}

- -
- ); -}; - -const KeyFactorField = ({ - keyFactor, - setKeyFactor, - isActive, - showXButton, - onXButtonClick, -}: { - keyFactor: string; - setKeyFactor: (keyFactor: string) => void; - isActive: boolean; - showXButton: boolean; - onXButtonClick: () => void; -}) => { - const t = useTranslations(); - - return ( -
- setKeyFactor(e.target.value)} - className="grow rounded bg-gray-0 px-3 py-2 text-base dark:bg-gray-0-dark" - readOnly={!isActive} - /> - {showXButton && ( - - )} -
- ); -}; +// Prevent heavy MDXEditor re-renders when unrelated state (like driver input) changes +const MemoMarkdownEditor = memo(MarkdownEditor); export const AddKeyFactorsForm = ({ - keyFactors, - setKeyFactors, - isActive, + drafts, + setDrafts, factorsLimit, limitError, suggestedKeyFactors, setSuggestedKeyFactors, + post, }: { - keyFactors: string[]; - setKeyFactors: (factors: string[]) => void; - isActive: boolean; + drafts: KeyFactorDraft[]; + setDrafts: React.Dispatch>; limitError?: string; factorsLimit: number; suggestedKeyFactors: { text: string; selected: boolean }[]; setSuggestedKeyFactors: ( factors: { text: string; selected: boolean }[] ) => void; + post: PostWithForecasts; }) => { const t = useTranslations(); const totalKeyFactorsLimitReached = - keyFactors.length + - suggestedKeyFactors.filter((kf) => kf.selected).length >= + drafts.length + suggestedKeyFactors.filter((kf) => kf.selected).length >= Math.min(factorsLimit, FACTORS_PER_COMMENT); return (
{suggestedKeyFactors.length > 0 && ( -
+

{t("suggestedKeyFactorsSection")}

@@ -159,45 +107,51 @@ export const AddKeyFactorsForm = ({
)} - {suggestedKeyFactors.length === 0 && ( -

{t("addKeyFactorsModalP1")}

- )} +
+ {suggestedKeyFactors.length === 0 && ( +

+ {t("addDriverModalDescription")} +

+ )} - {keyFactors.map((keyFactor, idx) => ( - { - setKeyFactors( - keyFactors.map((k, i) => (i === idx ? keyFactor : k)) - ); - }} - isActive={isActive} - showXButton={idx > 0 && isActive} - onXButtonClick={() => { - setKeyFactors(keyFactors.filter((_, i) => i !== idx)); - }} - /> - ))} + {drafts.map((draft, idx) => ( + + setDrafts(drafts.map((k, i) => (i === idx ? d : k))) + } + showXButton={idx > 0} + onXButtonClick={() => { + setDrafts(drafts.filter((_, i) => i !== idx)); + }} + post={post} + /> + ))} - {isActive && ( - )} +
); }; @@ -218,19 +172,21 @@ const AddKeyFactorsModal: FC = ({ isOpen, onClose, commentId, - postId, + post, onSuccess, user, showSuggestedKeyFactors = false, }) => { const t = useTranslations(); - const [currentStep, setCurrentStep] = useState(1); - const numberOfSteps = commentId ? 1 : 2; const [markdown, setMarkdown] = useState(""); + const [drafts, setDrafts] = useState([ + { + kind: "whole", + driver: { text: "", impact_direction: null, certainty: null }, + }, + ]); const { - keyFactors, - setKeyFactors, errors, setErrors, suggestedKeyFactors, @@ -244,13 +200,24 @@ const AddKeyFactorsModal: FC = ({ } = useKeyFactors({ user_id: user.id, commentId, - postId, + postId: post.id, suggestKeyFactors: showSuggestedKeyFactors && isOpen, }); - const handleOnClose = () => { - setCurrentStep(1); + const resetAll = () => { + setDrafts([ + { + kind: "whole", + driver: { text: "", impact_direction: null, certainty: null }, + }, + ]); + setMarkdown(""); + setErrors(undefined); clearState(); + }; + + const handleOnClose = () => { + resetAll(); onClose(true); }; @@ -260,13 +227,13 @@ const AddKeyFactorsModal: FC = ({ return; } - const result = await submit(keyFactors, suggestedKeyFactors, markdown); + const result = await submit(drafts, suggestedKeyFactors, markdown); if (result && "errors" in result) { setErrors(result.errors); return; } - clearState(); + resetAll(); if (result?.comment) { onSuccess?.(result.comment); } @@ -274,78 +241,78 @@ const AddKeyFactorsModal: FC = ({ }; return ( +

+ {t("addKeyFactors")} + {t("add")} + + + {t("driver")} + +

+ {isLoadingSuggestedKeyFactors && } {!isLoadingSuggestedKeyFactors && (
- {currentStep > 1 && ( - - )} + {/* Comment section */} +
+

+ {t("addDriverModalCommentDescription")} +

+ +
- {currentStep > 1 ? ( - - ) : ( - - )} + - {currentStep < numberOfSteps ? ( - - ) : ( - - )} +
diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx new file mode 100644 index 0000000000..6636d8a65b --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx @@ -0,0 +1,138 @@ +import { faCog, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import ImpactDirectionControls from "@/app/(main)/questions/[id]/components/key_factors/add_modal/impact_direction_controls"; +import Button from "@/components/ui/button"; +import { Input } from "@/components/ui/form_field"; +import { ImpactMetadata } from "@/types/comment"; +import { KeyFactorDraft } from "@/types/key_factors"; +import { PostWithForecasts } from "@/types/post"; +import { + inferEffectiveQuestionTypeFromPost, + isGroupOfQuestionsPost, + isQuestionPost, +} from "@/utils/questions/helpers"; + +import OptionTargetPicker, { Target } from "../option_target_picker"; + +type Props = { + draft: KeyFactorDraft; + setDraft: (d: KeyFactorDraft) => void; + showXButton: boolean; + onXButtonClick: () => void; + post: PostWithForecasts; +}; + +const DriverCreationForm: FC = ({ + draft, + setDraft, + showXButton, + onXButtonClick, + post, +}) => { + const t = useTranslations(); + const questionTypeBase = inferEffectiveQuestionTypeFromPost(post); + let questionType = questionTypeBase; + let effectiveUnit = isQuestionPost(post) ? post.question.unit : undefined; + + if (isGroupOfQuestionsPost(post) && draft.kind === "question") { + const sq = post.group_of_questions.questions.find( + (q) => q.id === draft.question_id + ); + questionType = sq?.type ?? questionTypeBase; + effectiveUnit = sq?.unit ?? effectiveUnit; + } + + return ( +
+
+
+ + {t("driver")} +
+ {showXButton && ( + + )} +
+ + setDraft({ + ...draft, + driver: { ...draft.driver, text: e.target.value }, + }) + } + className="grow rounded-none border-0 border-b border-blue-400 bg-transparent px-0 py-1 text-base text-blue-700 outline-0 placeholder:text-blue-700 placeholder:text-opacity-50 dark:border-blue-400-dark dark:text-blue-700-dark dark:placeholder:text-blue-700-dark" + /> +
+
+ {t("chooseDirectionOfImpact")} +
+ {questionType && ( + + setDraft({ ...draft, driver: { ...draft.driver, ...m } }) + } + questionType={questionType} + unit={effectiveUnit} + /> + )} + + setDraft( + t.kind === "whole" + ? ({ kind: "whole", driver: draft.driver } as KeyFactorDraft) + : t.kind === "question" + ? ({ + kind: "question", + question_id: t.question_id, + driver: draft.driver, + } as KeyFactorDraft) + : ({ + kind: "option", + question_id: t.question_id, + question_option: t.question_option, + driver: draft.driver, + } as KeyFactorDraft) + ) + } + /> +
+
+ ); +}; + +export default DriverCreationForm; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/impact_direction_controls.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/impact_direction_controls.tsx new file mode 100644 index 0000000000..20d456ff7b --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/impact_direction_controls.tsx @@ -0,0 +1,130 @@ +import { FC, useMemo } from "react"; + +import { KeyFactorImpactDirectionLabel } from "@/app/(main)/questions/[id]/components/key_factors/key_factors_impact_direction"; +import LikelihoodButton from "@/app/(main)/questions/[id]/components/key_factors/likehood_button"; +import { ImpactDirectionCategory, ImpactMetadata } from "@/types/comment"; +import { QuestionType } from "@/types/question"; + +type ImpactDirectionControlsProps = { + questionType: QuestionType; + impact: { impact_direction: 1 | -1 | null; certainty: -1 | null } | null; + onSelect: (impactMetadata: ImpactMetadata) => void; + unit?: string; +}; + +type ButtonConfig = + | { + direction: 1 | -1; + impact: ImpactDirectionCategory; + variant: "green" | "red"; + certainty?: null; + } + | { + direction: null; + impact: ImpactDirectionCategory; + variant: "neutral"; + certainty: -1; + }; + +const ImpactDirectionControls: FC = ({ + questionType, + impact, + onSelect, + unit, +}) => { + const certainty = impact?.certainty ?? null; + const impact_direction = impact?.impact_direction ?? null; + const impactMap: Record< + "positive" | "negative", + Partial> & { + default: ImpactDirectionCategory; + } + > = { + positive: { + [QuestionType.Date]: ImpactDirectionCategory.Earlier, + [QuestionType.Numeric]: ImpactDirectionCategory.More, + [QuestionType.Discrete]: ImpactDirectionCategory.More, + default: ImpactDirectionCategory.Increase, + }, + negative: { + [QuestionType.Date]: ImpactDirectionCategory.Later, + [QuestionType.Numeric]: ImpactDirectionCategory.Less, + [QuestionType.Discrete]: ImpactDirectionCategory.Less, + default: ImpactDirectionCategory.Decrease, + }, + }; + + const includeUncertainty = + questionType === QuestionType.Numeric || + questionType === QuestionType.Discrete || + questionType === QuestionType.Date; + + const buttons: ButtonConfig[] = useMemo(() => { + const baseButtons: ButtonConfig[] = [ + { + direction: 1, + impact: impactMap.positive[questionType] ?? impactMap.positive.default, + variant: "green", + certainty: null, + }, + { + direction: -1, + impact: impactMap.negative[questionType] ?? impactMap.negative.default, + variant: "red", + certainty: null, + }, + ]; + + return includeUncertainty + ? [ + ...baseButtons, + { + direction: null, + impact: ImpactDirectionCategory.IncreaseUncertainty, + variant: "neutral", + certainty: -1, + }, + ] + : baseButtons; + }, [ + impactMap.positive, + impactMap.negative, + questionType, + includeUncertainty, + ]); + + return ( +
+ {buttons.map( + ({ direction, certainty: btnCertainty, impact, variant }) => ( + { + if (btnCertainty === -1) { + onSelect({ impact_direction: null, certainty: -1 }); + } else { + onSelect({ + impact_direction: direction as 1 | -1, + certainty: null, + }); + } + }} + selected={ + (direction !== null && impact_direction === direction) || + (btnCertainty === -1 && certainty === -1) + } + > + + + ) + )} +
+ ); +}; + +export default ImpactDirectionControls; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts index 6ec6ebcbc0..129edf98f5 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts @@ -1,6 +1,6 @@ import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import { useState, useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import { @@ -9,13 +9,12 @@ import { } from "@/app/(main)/questions/actions"; import { useServerAction } from "@/hooks/use_server_action"; import ClientCommentsApi from "@/services/api/comments/comments.client"; -import { BECommentType, KeyFactor } from "@/types/comment"; +import { KeyFactorWritePayload } from "@/services/api/comments/comments.shared"; +import { BECommentType, Driver, KeyFactor } from "@/types/comment"; import { ErrorResponse } from "@/types/fetch"; +import { KeyFactorDraft } from "@/types/key_factors"; import { sendAnalyticsEvent } from "@/utils/analytics"; -const FACTORS_PER_QUESTION = 6; -const FACTORS_PER_COMMENT = 4; - export type SuggestedKeyFactor = { text: string; selected: boolean; @@ -40,7 +39,7 @@ export const useKeyFactors = ({ const { comments, setComments, combinedKeyFactors, setCombinedKeyFactors } = useCommentsFeed(); - const [keyFactors, setKeyFactors] = useState([""]); + // The drafts are managed by the caller now const [errors, setErrors] = useState(); const [suggestedKeyFactors, setSuggestedKeyFactors] = useState< SuggestedKeyFactor[] @@ -48,6 +47,21 @@ export const useKeyFactors = ({ const [isLoadingSuggestedKeyFactors, setIsLoadingSuggestedKeyFactors] = useState(false); + const applyTargetForDraft = ( + draft: KeyFactorDraft, + payload: KeyFactorWritePayload + ): KeyFactorWritePayload => { + if (draft.kind === "question") + return { ...payload, question_id: draft.question_id }; + if (draft.kind === "option") + return { + ...payload, + question_id: draft.question_id, + question_option: draft.question_option, + }; + return payload; + }; + useEffect(() => { if (shouldLoadKeyFactors && commentId) { setIsLoadingSuggestedKeyFactors(true); @@ -57,6 +71,14 @@ export const useKeyFactors = ({ suggested.map((text) => ({ text, selected: false })) ); onKeyFactorsLoadded?.(suggested.length !== 0); + if (suggested.length > 0) { + setTimeout(() => { + const el = document.getElementById("suggested-key-factors"); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 50); + } }) .catch(() => { onKeyFactorsLoadded?.(false); @@ -82,7 +104,7 @@ export const useKeyFactors = ({ : undefined; const onSubmit = async ( - keyFactors: string[], + submittedDrafts: KeyFactorDraft[], suggestedKeyFactors: SuggestedKeyFactor[], markdown?: string ): Promise< @@ -95,28 +117,42 @@ export const useKeyFactors = ({ comment: BECommentType; } > => { - for (const keyFactor of keyFactors) { - if (keyFactor.trim().length > 150) { + for (const draft of submittedDrafts) { + if (draft.driver.text.trim().length > 150) { return { errors: new Error(t("maxKeyFactorLength")) }; } } - const filteredKeyFactors = keyFactors.filter((f) => f.trim() !== ""); + const filteredDrafts = submittedDrafts.filter( + (d) => d.driver.text.trim() !== "" + ); const filteredSuggestedKeyFactors = suggestedKeyFactors .filter((kf) => kf.selected) .map((kf) => kf.text); + const writePayloads: KeyFactorWritePayload[] = [ + ...filteredDrafts.map((d) => + applyTargetForDraft(d, { + driver: toDriverUnion({ + text: d.driver.text, + impact_direction: d.driver.impact_direction ?? null, + certainty: d.driver.certainty ?? null, + }), + }) + ), + ...filteredSuggestedKeyFactors.map((text) => ({ + driver: toDriverUnion({ text, impact_direction: 1, certainty: null }), + })), + ]; + let comment; if (commentId) { - comment = await addKeyFactorsToComment(commentId, [ - ...filteredKeyFactors, - ...filteredSuggestedKeyFactors, - ]); + comment = await addKeyFactorsToComment(commentId, writePayloads); } else { comment = await createComment({ on_post: postId, text: markdown || "", - key_factors: [...filteredKeyFactors, ...filteredSuggestedKeyFactors], + key_factors: writePayloads, is_private: false, }); } @@ -148,14 +184,11 @@ export const useKeyFactors = ({ const [submit, isPending] = useServerAction(onSubmit); const clearState = () => { - setKeyFactors([""]); setErrors(undefined); setSuggestedKeyFactors([]); }; return { - keyFactors, - setKeyFactors, errors, setErrors, suggestedKeyFactors, @@ -176,6 +209,9 @@ export const getKeyFactorsLimits = ( user_id: number | undefined, commentId?: number ) => { + const FACTORS_PER_QUESTION = 6; + const FACTORS_PER_COMMENT = 4; + if (isNil(user_id)) { return { userPostFactors: [], @@ -204,3 +240,20 @@ export const getKeyFactorsLimits = ( factorsLimit, }; }; + +type DriverDraft = { + text: string; + impact_direction: 1 | -1 | null; + certainty: -1 | null; +}; + +function toDriverUnion(d: DriverDraft): Driver { + if (d.certainty === -1) { + return { text: d.text, impact_direction: null, certainty: -1 }; + } + const dir = d.impact_direction; + if (dir === 1 || dir === -1) { + return { text: d.text, impact_direction: dir, certainty: null }; + } + return { text: d.text, impact_direction: 1, certainty: null }; +} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/index.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/index.tsx index 4cf995ea32..66359a3bb7 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/index.tsx @@ -1,64 +1,63 @@ "use client"; import dynamic from "next/dynamic"; -import { useFeatureFlagVariantKey } from "posthog-js/react"; import { FC } from "react"; import { KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import cn from "@/utils/core/cn"; + +import KeyFactorDriver from "./key_factor_driver"; -import LikertKeyFactorItem from "./likert_item"; -import TwoStepKeyFactorItem from "./two_step_item"; -import UpdownKeyFactorItem from "./updown_item"; type Props = { keyFactor: KeyFactor; + post: PostWithForecasts; linkToComment?: boolean; - variant?: "default" | "compact"; + isCompact?: boolean; + mode?: "forecaster" | "consumer"; + onClick?: () => void; + className?: string; }; -const FEATURE_FLAG_KEY = "key-factors-p2"; -const LAYOUT_VARIANTS = { - UP_DOWN: "default", - TWO_STEP: "2-step-survey", - LIKERT: "likert-scale", -} as const; - export const KeyFactorItem: FC = ({ keyFactor, + post, linkToComment = true, - variant = "default", + isCompact, + mode, + onClick, + className, }) => { - const layoutVariant = useFeatureFlagVariantKey(FEATURE_FLAG_KEY); - const linkAnchor = linkToComment - ? `#comment-${keyFactor.comment_id}` - : "#key-factors"; + const isCompactConsumer = mode === "consumer" && isCompact; - switch (layoutVariant) { - case LAYOUT_VARIANTS.TWO_STEP: - return ( - - ); - case LAYOUT_VARIANTS.LIKERT: - return ( - - ); - default: - return ( - + {keyFactor.driver && ( + - ); - } + )} +
+ ); }; export default dynamic(() => Promise.resolve(KeyFactorItem), { diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_driver.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_driver.tsx new file mode 100644 index 0000000000..fed236e125 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_driver.tsx @@ -0,0 +1,88 @@ +"use client"; +import { isNil } from "lodash"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import KeyFactorHeader from "@/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_header"; +import { KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import cn from "@/utils/core/cn"; +import { inferEffectiveQuestionTypeFromPost } from "@/utils/questions/helpers"; + +import KeyFactorImpactDirectionContainer, { + convertNumericImpactToDirectionCategory, +} from "../key_factors_impact_direction"; +import KeyFactorStrengthVoter from "./key_factor_strength_voter"; +import KeyFactorText from "./key_factor_text"; + +type Props = { + keyFactor: KeyFactor; + isCompact?: boolean; + mode?: "forecaster" | "consumer"; + post: PostWithForecasts; +}; + +const KeyFactorDriver: FC = ({ + keyFactor, + isCompact, + mode = "forecaster", + post, +}) => { + const { driver } = keyFactor; + const t = useTranslations(); + const questionType = inferEffectiveQuestionTypeFromPost(post); + const directionCategory = + questionType && + convertNumericImpactToDirectionCategory( + driver.impact_direction, + driver.certainty, + questionType + ); + + const isConsumer = mode === "consumer"; + const isCompactConsumer = isConsumer && isCompact; + + return ( + <> + {!isConsumer && ( + + )} + + + + {!isNil(directionCategory) && ( + + )} + + {mode === "forecaster" &&
} + + + + ); +}; + +export default KeyFactorDriver; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_header.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_header.tsx new file mode 100644 index 0000000000..d2b23119ba --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_header.tsx @@ -0,0 +1,52 @@ +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import useScrollTo from "@/hooks/use_scroll_to"; +import { sendAnalyticsEvent } from "@/utils/analytics"; + +type Props = { + label: string; + username: string; + linkAnchor: string; + className?: string; +}; + +const KeyFactorHeader: FC = ({ label, username, linkAnchor }) => { + const t = useTranslations(); + const scrollTo = useScrollTo(); + + return ( + + ); +}; + +export default KeyFactorHeader; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_strength_voter.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_strength_voter.tsx new file mode 100644 index 0000000000..c516bfb1da --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_strength_voter.tsx @@ -0,0 +1,180 @@ +import { isNil } from "lodash"; +import { useTranslations } from "next-intl"; +import React, { + ButtonHTMLAttributes, + FC, + PropsWithChildren, + useState, +} from "react"; + +import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; +import { voteKeyFactor } from "@/app/(main)/questions/actions"; +import { useAuth } from "@/contexts/auth_context"; +import { + KeyFactorVoteAggregate, + KeyFactorVoteTypes, + StrengthValues, + StrengthVoteOption, +} from "@/types/comment"; +import { sendAnalyticsEvent } from "@/utils/analytics"; +import cn from "@/utils/core/cn"; +import { logError } from "@/utils/core/errors"; + +import SegmentedProgressBar from "./segmented_progress_bar"; + +type Props = { + keyFactorId: number; + vote: KeyFactorVoteAggregate; + className?: string; + onVoteSuccess?: (newScore: number, newUserVote: number | null) => void; + allowVotes?: boolean; + mode?: "forecaster" | "consumer"; +}; + +const StrengthScale: FC<{ + score: number; + count: number; + mode?: "forecaster" | "consumer"; +}> = ({ score, count, mode }) => { + const t = useTranslations(); + + const clamped = Math.max(0, Math.min(5, score ?? 0)) / 5; + return ( +
+
+
+ {t("strength")} +
+
+ {t("votesWithCount", { count })} +
+
+
+ +
+
+ ); +}; + +const KeyFactorStrengthVoter: FC = ({ + keyFactorId, + vote, + className, + onVoteSuccess, + allowVotes, + mode = "forecaster", +}) => { + const t = useTranslations(); + const { user } = useAuth(); + const { setKeyFactorVote } = useCommentsFeed(); + + const [aggregate, setAggregate] = useState(vote); + const [isSubmitting, setIsSubmitting] = useState(false); + + const submitVote = async (newValue: StrengthVoteOption | null) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + // Optimistic vote update + setAggregate({ ...aggregate, user_vote: newValue }); + + const response = await voteKeyFactor({ + id: keyFactorId, + vote: newValue, + user: user?.id ?? 0, + vote_type: KeyFactorVoteTypes.STRENGTH, + }); + + sendAnalyticsEvent("KeyFactorVote", { + event_category: "none", + event_label: isNil(newValue) ? "null" : newValue.toString(), + variant: "strength", + }); + + if (response) { + const returned = response as unknown as KeyFactorVoteAggregate; + setAggregate(returned); + setKeyFactorVote(keyFactorId, returned); + onVoteSuccess?.(returned.score, returned.user_vote); + } + } catch (e) { + logError(e); + } finally { + setIsSubmitting(false); + } + }; + + const handleSelect = (value: StrengthVoteOption) => { + const next = aggregate.user_vote === value ? null : value; + submitVote(next); + }; + + const voteOptions = [ + { value: StrengthValues.NO_IMPACT, label: t("noImpact") }, + { value: StrengthValues.LOW, label: t("lowStrength") }, + { value: StrengthValues.MEDIUM, label: t("mediumStrength") }, + { value: StrengthValues.HIGH, label: t("highStrength") }, + ]; + + return ( +
+ + {user && allowVotes && ( +
+
+ {t("vote")} +
+
+ {voteOptions.map(({ value, label }) => ( + handleSelect(value)} + > + {label} + + ))} +
+
+ )} +
+ ); +}; + +export default KeyFactorStrengthVoter; + +type KFButtonProps = PropsWithChildren< + ButtonHTMLAttributes & { selected?: boolean } +>; + +const KFButton: FC = ({ + selected, + className, + children, + ...rest +}) => { + return ( + + ); +}; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_text.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_text.tsx index f7a5fd80df..13c87fb366 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_text.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/key_factor_text.tsx @@ -1,60 +1,21 @@ -import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FC } from "react"; -import useScrollTo from "@/hooks/use_scroll_to"; -import { sendAnalyticsEvent } from "@/utils/analytics"; import cn from "@/utils/core/cn"; type Props = { text: string; - linkAnchor?: string; - linkToComment?: boolean; className?: string; }; -const KeyFactorText: FC = ({ - text, - linkAnchor, - linkToComment = true, - className, -}) => { - const scrollTo = useScrollTo(); - +const KeyFactorText: FC = ({ text, className }) => { return ( ); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/likert_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/likert_item.tsx deleted file mode 100644 index 68c013f43f..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/likert_item.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; -import { faCircle } from "@fortawesome/free-regular-svg-icons"; -import { faCircleCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { isNil } from "lodash"; -import { useTranslations } from "next-intl"; -import { FC, useEffect, useMemo, useState } from "react"; - -import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; -import { voteKeyFactor } from "@/app/(main)/questions/actions"; -import Button from "@/components/ui/button"; -import { useAuth } from "@/contexts/auth_context"; -import { useModal } from "@/contexts/modal_context"; -import { - ImpactValues, - KeyFactor, - KeyFactorVoteScore, - KeyFactorVoteTypes, -} from "@/types/comment"; -import { sendAnalyticsEvent } from "@/utils/analytics"; -import cn from "@/utils/core/cn"; -import { logError } from "@/utils/core/errors"; - -import KeyFactorText from "./key_factor_text"; - -type Props = { - keyFactor: KeyFactor; - linkToComment?: boolean; - linkAnchor: string; -}; - -export const LikertKeyFactorItem: FC = ({ - keyFactor: { - driver: { text }, - id, - user_votes, - }, - linkToComment = true, - linkAnchor, -}) => { - const t = useTranslations(); - const { user } = useAuth(); - const { setCurrentModal } = useModal(); - const { setKeyFactorVote } = useCommentsFeed(); - const likertVote = useMemo( - () => - user_votes.find((vote) => vote.vote_type === KeyFactorVoteTypes.LIKERT), - [user_votes] - ); - const [voteScore, setVoteScore] = useState( - !isNil(likertVote?.score) ? likertVote.score : null - ); - const [showVoter, setShowVoter] = useState(false); - - const handleVote = async (score: KeyFactorVoteScore) => { - if (!user) { - setCurrentModal({ type: "signin" }); - return; - } - - try { - const newScore = score === voteScore ? null : score; - const response = await voteKeyFactor({ - id, - vote: newScore, - user: user.id, - vote_type: KeyFactorVoteTypes.LIKERT, - }); - - sendAnalyticsEvent("KeyFactorVote", { - event_category: "none", - event_label: isNil(newScore) ? "null" : newScore.toString(), - variant: "likert", - }); - - if (response && "score" in response) { - setVoteScore(newScore); - setKeyFactorVote( - id, - { - vote_type: KeyFactorVoteTypes.LIKERT, - score: newScore, - }, - response.score as number - ); - } - } catch (error) { - logError(error); - } - }; - - // update key factor state in other place on the page - useEffect(() => { - if (likertVote) { - setVoteScore(likertVote.score); - } - }, [likertVote]); - - return ( -
-
- - - -
- - {showVoter && ( -
-

- {t("howInfluenceForecast")} -

- -
- {VOTE_BUTTONS.map((button, index) => ( - - ))} -
-
-

{t("decreases")}

-

- {t("noImpact")} -

-

{t("increases")}

-
- {!isNil(voteScore) && ( -
- {t("thankYouForSubmission")} -
- )} -
- )} -
- ); -}; - -const VOTE_BUTTONS = [ - { - score: ImpactValues.HIGH_NEGATIVE, - children: ( - <> - - - - - - - - ), - className: - "bg-salmon-300 dark:bg-salmon-300-dark active:bg-salmon-300 active:dark:bg-salmon-300-dark text-salmon-700 dark:text-salmon-700-dark hover:bg-salmon-400 hover:border-solid hover:border-salmon-500 hover:dark:border-salmon-500-dark hover:dark:bg-salmon-400-dark", - activeClassName: - "bg-salmon-800 dark:bg-salmon-800-dark hover:bg-salmon-800 hover:dark:bg-salmon-800-dark", - }, - { - score: ImpactValues.MEDIUM_NEGATIVE, - children: ( - <> - - - - - - ), - className: - "bg-salmon-200 dark:bg-salmon-200-dark active:bg-salmon-200 active:dark:bg-salmon-200-dark text-salmon-700 dark:text-salmon-700-dark hover:bg-salmon-300 hover:dark:bg-salmon-300-dark hover:border-solid hover:border-salmon-400 hover:dark:border-salmon-400-dark", - activeClassName: - "bg-salmon-800 dark:bg-salmon-800-dark hover:bg-salmon-800 hover:dark:bg-salmon-800-dark", - }, - { - score: ImpactValues.LOW_NEGATIVE, - children: -, - className: - "bg-salmon-100 dark:bg-salmon-100-dark active:bg-salmon-100 active:dark:bg-salmon-100-dark text-salmon-700 dark:text-salmon-700-dark hover:bg-salmon-200 hover:dark:bg-salmon-200-dark hover:border-solid hover:border-salmon-300 hover:dark:border-salmon-300-dark", - activeClassName: - "bg-salmon-800 dark:bg-salmon-800-dark hover:bg-salmon-800 hover:dark:bg-salmon-800-dark", - }, - { - score: ImpactValues.NO_IMPACT, - children: , - className: - "bg-blue-100 dark:bg-blue-100-dark active:bg-blue-100 active:dark:bg-blue-100-dark text-blue-500 dark:text-blue-500-dark hover:bg-blue-200 hover:dark:bg-blue-200-dark hover:border-solid hover:border-blue-500 hover:dark:border-blue-500-dark", - activeClassName: - "bg-blue-700 dark:bg-blue-700-dark hover:bg-blue-700 hover:dark:bg-blue-700-dark", - }, - { - score: ImpactValues.LOW, - children: +, - className: - "bg-mint-200 dark:bg-mint-200-dark active:bg-mint-200 active:dark:bg-mint-200-dark text-mint-800 dark:text-mint-800-dark hover:bg-mint-300 hover:dark:bg-mint-300-dark hover:border-solid hover:border-mint-400 hover:dark:border-mint-400-dark", - activeClassName: - "bg-mint-800 dark:bg-mint-800-dark hover:bg-mint-800 hover:dark:bg-mint-800-dark", - }, - { - score: ImpactValues.MEDIUM, - children: ( - <> - + - + - - ), - className: - "bg-mint-300 dark:bg-mint-300-dark active:bg-mint-300 active:dark:bg-mint-300-dark text-mint-800 dark:text-mint-800-dark hover:bg-mint-400 hover:dark:bg-mint-400-dark hover:border-solid hover:border-mint-500 hover:dark:border-mint-500-dark", - activeClassName: - "bg-mint-800 dark:bg-mint-800-dark hover:bg-mint-800 hover:dark:bg-mint-800-dark", - }, - { - score: ImpactValues.HIGH, - children: ( - <> - + - + - + - - ), - className: - "bg-mint-400 dark:bg-mint-400-dark active:bg-mint-400 active:dark:bg-mint-400-dark text-mint-800 dark:text-mint-800-dark hover:bg-mint-500 hover:dark:bg-mint-500-dark hover:border-solid hover:border-mint-600 hover:dark:border-mint-600-dark", - activeClassName: - "bg-mint-800 dark:bg-mint-800-dark hover:bg-mint-800 hover:dark:bg-mint-800-dark", - }, -]; - -export default LikertKeyFactorItem; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/segmented_progress_bar.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/segmented_progress_bar.tsx new file mode 100644 index 0000000000..58266d4c15 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/segmented_progress_bar.tsx @@ -0,0 +1,91 @@ +import dynamic from "next/dynamic"; +import React, { FC, useId } from "react"; + +import { METAC_COLORS } from "@/constants/colors"; +import useAppTheme from "@/hooks/use_app_theme"; +import useContainerSize from "@/hooks/use_container_size"; + +type Props = { + progress: number; + segments: number; +}; + +const SegmentedProgressBar: FC = ({ progress, segments }) => { + const maskId = useId(); + const { getThemeColor } = useAppTheme(); + + const GAP_PX = 1; + const HEIGHT_PX = 10; + const RADIUS_PX = 1; + const FILL_COLOR = getThemeColor(METAC_COLORS.olive["600"]); + const EMPTY_COLOR = getThemeColor(METAC_COLORS.blue["400"]); + + // Measure actual container width + const { ref, width } = useContainerSize(); + + // Compute pixel geometry + const totalGaps = (segments - 1) * GAP_PX; + const segW = width > 0 ? (width - totalGaps) / segments : 0; + const xs = Array.from({ length: segments }, (_, i) => i * (segW + GAP_PX)); + + return ( +
+ + {/* background */} + + {width > 0 && + xs.map((x, i) => ( + + ))} + + + {/* mask */} + + + + + {width > 0 && + xs.map((x, i) => ( + + ))} + + + + + {/* animated fill */} + + + + +
+ ); +}; + +export default dynamic(() => Promise.resolve(SegmentedProgressBar), { + ssr: false, +}); diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/two_step_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/two_step_item.tsx deleted file mode 100644 index 3e7584160f..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/two_step_item.tsx +++ /dev/null @@ -1,302 +0,0 @@ -"use client"; - -import { - faArrowUp, - faArrowDown, - faXmark, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { isNil } from "lodash"; -import { useTranslations } from "next-intl"; -import { FC, useEffect, useMemo, useState } from "react"; - -import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; -import { voteKeyFactor } from "@/app/(main)/questions/actions"; -import Button from "@/components/ui/button"; -import { useAuth } from "@/contexts/auth_context"; -import { useModal } from "@/contexts/modal_context"; -import { - ImpactValues, - KeyFactor, - KeyFactorVoteScore, - KeyFactorVoteTypes, -} from "@/types/comment"; -import { sendAnalyticsEvent } from "@/utils/analytics"; -import cn from "@/utils/core/cn"; -import { logError } from "@/utils/core/errors"; - -import KeyFactorText from "./key_factor_text"; - -type Props = { - keyFactor: KeyFactor; - linkAnchor: string; - linkToComment?: boolean; -}; - -export const TwoStepKeyFactorItem: FC = ({ - keyFactor: { - driver: { text }, - id, - user_votes, - }, - linkToComment = true, - linkAnchor, -}) => { - const t = useTranslations(); - const { user } = useAuth(); - const { setCurrentModal } = useModal(); - const { setKeyFactorVote } = useCommentsFeed(); - const twoStepVote = useMemo( - () => - user_votes.find((vote) => vote.vote_type === KeyFactorVoteTypes.TWO_STEP), - [user_votes] - ); - const [voteScore, setVoteScore] = useState( - !isNil(twoStepVote?.score) ? twoStepVote.score : null - ); - const [showSecondStep, setShowSecondStep] = useState( - twoStepVote?.show_second_step ?? false - ); - const [isSecondStepCompleted, setIsSecondStepCompleted] = useState(false); - - const handleVote = async ( - score: KeyFactorVoteScore, - isFirstStep: boolean = true - ) => { - if (!user) { - setCurrentModal({ type: "signin" }); - return; - } - - try { - let secondStepCompletion = !isFirstStep; - let newScore = - isFirstStep && - !isNil(voteScore) && - !isNil(score) && - ((voteScore < 0 && score < 0) || (voteScore > 0 && score > 0)) - ? null - : score; - if ( - !isFirstStep && - !isNil(score) && - score === voteScore && - isSecondStepCompleted - ) { - newScore = - score < 0 ? ImpactValues.MEDIUM_NEGATIVE : ImpactValues.MEDIUM; - secondStepCompletion = false; - } - - const response = await voteKeyFactor({ - id, - vote: newScore, - user: user.id, - vote_type: KeyFactorVoteTypes.TWO_STEP, - }); - - sendAnalyticsEvent("KeyFactorVote", { - event_category: isFirstStep ? "first_step" : "second_step", - event_label: isNil(newScore) ? "null" : newScore.toString(), - variant: "2-step", - }); - - if (response && "score" in response) { - if (isFirstStep) { - setShowSecondStep(!isNil(newScore)); - } - setIsSecondStepCompleted(secondStepCompletion); - setVoteScore(newScore); - setKeyFactorVote( - id, - { - vote_type: KeyFactorVoteTypes.TWO_STEP, - score: newScore, - show_second_step: !isNil(newScore) ? true : false, - second_step_completed: secondStepCompletion, - }, - response.score as number - ); - } - } catch (error) { - logError(error); - } - }; - - // update key factor state in other place on the page - useEffect(() => { - if (twoStepVote) { - setVoteScore(twoStepVote.score); - setIsSecondStepCompleted(twoStepVote.second_step_completed ?? false); - setShowSecondStep(twoStepVote.show_second_step ?? false); - } - }, [twoStepVote]); - - return ( -
- -
- - -
- {!isNil(voteScore) && showSecondStep && ( -
-
- {t("howImpactfulFactor")} -
- -
- - - -
- {isSecondStepCompleted && ( -

0, - })} - > - {t("thankYouForSubmission")} -

- )} -
- )} -
- ); -}; - -export default TwoStepKeyFactorItem; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/updown_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/updown_item.tsx deleted file mode 100644 index 6e0d06fbe3..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_item/updown_item.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; -import { FC } from "react"; - -import KeyFactorVoter from "@/app/(main)/questions/[id]/components/key_factors/key_factor_voter"; -import { KeyFactor, KeyFactorVoteTypes } from "@/types/comment"; -import cn from "@/utils/core/cn"; - -import KeyFactorText from "./key_factor_text"; - -type Props = { - keyFactor: KeyFactor; - linkToComment?: boolean; - linkAnchor: string; - variant?: "default" | "compact"; -}; - -export const UpdownKeyFactorItem: FC = ({ - keyFactor: { - driver: { text }, - id, - votes_score, - user_votes, - }, - linkToComment = true, - linkAnchor, - variant = "default", -}) => { - return ( -
- vote.vote_type === KeyFactorVoteTypes.UP_DOWN - ) ?? null, - }} - /> - -
- ); -}; - -export default UpdownKeyFactorItem; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_voter.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_voter.tsx deleted file mode 100644 index 9ed0d276a6..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_voter.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; -import { isNil } from "lodash"; -import { FC, useEffect, useState } from "react"; - -import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; -import { voteKeyFactor } from "@/app/(main)/questions/actions"; -import Voter from "@/components/voter"; -import { useAuth } from "@/contexts/auth_context"; -import { useModal } from "@/contexts/modal_context"; -import { KeyFactorVote, KeyFactorVoteTypes } from "@/types/comment"; -import { VoteDirection } from "@/types/votes"; -import { sendAnalyticsEvent } from "@/utils/analytics"; -import cn from "@/utils/core/cn"; -import { logError } from "@/utils/core/errors"; - -type Props = { - voteData: VoteData; - className?: string; -}; - -type VoteData = { - keyFactorId: number; - votesScore?: number | null; - userVote: KeyFactorVote | null; -}; - -const KeyFactorVoter: FC = ({ voteData, className }) => { - const { user } = useAuth(); - const { setCurrentModal } = useModal(); - - const [userVote, setUserVote] = useState(voteData.userVote); - const [votesScore, setVotesScore] = useState(voteData.votesScore); - const { setKeyFactorVote } = useCommentsFeed(); - - // Update local state when voteData changes - useEffect(() => { - setUserVote(voteData.userVote); - setVotesScore(voteData.votesScore); - }, [voteData.userVote, voteData.votesScore]); - - const handleVote = async (vote: KeyFactorVote) => { - if (!user) { - setCurrentModal({ type: "signin" }); - return; - } - - try { - const newScore = userVote?.score === vote.score ? null : vote.score; - const response = await voteKeyFactor({ - id: voteData.keyFactorId, - vote: newScore, - user: user.id, - vote_type: vote.vote_type, - }); - - sendAnalyticsEvent("KeyFactorVote", { - event_category: "none", - event_label: isNil(newScore) ? "null" : newScore.toString(), - variant: "updown", - }); - - if (response && "score" in response) { - const newVotesScore = response.score as number; - - setKeyFactorVote( - voteData.keyFactorId, - { ...vote, score: newScore }, - newVotesScore - ); - - setUserVote({ ...vote, score: newScore }); - setVotesScore(newVotesScore); - } - } catch (e) { - logError(e); - } - }; - return ( - - handleVote({ vote_type: KeyFactorVoteTypes.UP_DOWN, score: 1 }) - } - onVoteDown={() => - handleVote({ vote_type: KeyFactorVoteTypes.UP_DOWN, score: -1 }) - } - commentArea={true} - upChevronClassName="h-2.5 w-2.5 rounded-bl-[3px] rounded-tl-[3px] p-1.5" - downChevronClassName="h-2.5 w-2.5 rounded-br-[3px] rounded-tr-[3px] p-1.5" - /> - ); -}; - -export default KeyFactorVoter; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx new file mode 100644 index 0000000000..c0db4e709e --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx @@ -0,0 +1,52 @@ +"use client"; + +import React from "react"; + +import ReusableGradientCarousel from "@/components/gradient-carousel"; +import { useBreakpoint } from "@/hooks/tailwind"; +import cn from "@/utils/core/cn"; + +type Props = { + items: T[]; + renderItem: (item: T) => React.ReactNode; + listClassName?: string; +}; + +function KeyFactorsCarousel(props: Props) { + const { items, renderItem, listClassName } = props; + + const isDesktop = useBreakpoint("sm"); + + return ( + { + if (!isDesktop) return false; + // Desktop arrows logic: + // - At start: right gradient + right arrow + // - Middle: gradients on both ends, but NO arrows + // - At end: left gradient + left arrow + return ( + (state.canPrev && !state.canNext) || (!state.canPrev && state.canNext) + ); + }} + showGradients={(state) => + !isDesktop + ? { + left: state.canPrev && !state.canNext, + right: state.canNext && !state.canPrev, + } + : true + } + itemClassName="" + gapClassName="gap-2.5" + listClassName={cn("px-0", listClassName)} + gradientFromClass="from-gray-0 dark:from-gray-0-dark w-[55px]" + arrowClassName="right-1.5 w-10 h-10 md:w-[44px] md:h-[44px] text-blue-700 dark:text-blue-700-dark bg-gray-0 dark:bg-gray-0-dark mt-3 md:text-gray-200 md:dark:text-gray-200-dark rounded-full md:bg-blue-900 md:dark:bg-blue-900-dark" + renderItem={(item) => renderItem(item)} + /> + ); +} + +export default KeyFactorsCarousel; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx new file mode 100644 index 0000000000..f0bc24158a --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import { KeyFactorItem } from "@/app/(main)/questions/[id]/components/key_factors/key_factor_item"; +import KeyFactorsCarousel from "@/app/(main)/questions/[id]/components/key_factors/key_factors_carousel"; +import useScrollTo from "@/hooks/use_scroll_to"; +import { CommentType, KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import { sendAnalyticsEvent } from "@/utils/analytics"; + +import { useKeyFactorsContext } from "./key_factors_provider"; + +type Props = { + keyFactors: KeyFactor[]; + comment: CommentType; + post: PostWithForecasts; +}; + +const KeyFactorsCommentSection: FC = ({ post, keyFactors }) => { + const t = useTranslations(); + const scrollTo = useScrollTo(); + const { requestExpand } = useKeyFactorsContext(); + + return ( +
+
+ {t("keyFactors")} +
+ + ( + { + e.preventDefault(); + // Expand immediately to avoid post-scroll delay + requestExpand(); + const target = document.getElementById("key-factors"); + if (target) { + scrollTo(target.getBoundingClientRect().top); + } + sendAnalyticsEvent("KeyFactorClick", { + event_label: "fromComment", + }); + }} + > + + + )} + /> +
+ ); +}; + +export default KeyFactorsCommentSection; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_consumer_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_consumer_section.tsx new file mode 100644 index 0000000000..2726c8d789 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_consumer_section.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import { useBreakpoint } from "@/hooks/tailwind"; +import { KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; + +import { KeyFactorItem } from "./key_factor_item"; +import KeyFactorsCarousel from "./key_factors_carousel"; + +type Props = { + keyFactors: KeyFactor[]; + post: PostWithForecasts; +}; + +const KeyFactorsConsumerSection: FC = ({ post, keyFactors }) => { + const t = useTranslations(); + const isDesktop = useBreakpoint("sm"); + + return ( +
+
+ {t("topKeyFactors")} +
+ + ( + + )} + /> +
+ ); +}; + +export default KeyFactorsConsumerSection; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_impact_direction.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_impact_direction.tsx new file mode 100644 index 0000000000..ce60bfd1ad --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_impact_direction.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { + faArrowDown, + faArrowLeft, + faArrowRight, + faArrowUp, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import RichText from "@/components/rich_text"; +import { ImpactDirectionCategory } from "@/types/comment"; +import { QuestionType } from "@/types/question"; +import cn from "@/utils/core/cn"; + +type Props = { + impact: ImpactDirectionCategory; + className?: string; + option?: string; + unit?: string; + isCompact?: boolean; +}; + +export const convertNumericImpactToDirectionCategory = ( + impactDirection: -1 | 1 | null, + certainty: -1 | null, + questionType: QuestionType +): ImpactDirectionCategory | null => { + if (certainty === -1) { + return ImpactDirectionCategory.IncreaseUncertainty; + } + + switch (questionType) { + case QuestionType.Binary: + case QuestionType.MultipleChoice: + return impactDirection === -1 + ? ImpactDirectionCategory.Decrease + : ImpactDirectionCategory.Increase; + + case QuestionType.Numeric: + case QuestionType.Discrete: + return impactDirection === -1 + ? ImpactDirectionCategory.Less + : ImpactDirectionCategory.More; + + case QuestionType.Date: + return impactDirection === -1 + ? ImpactDirectionCategory.Earlier + : ImpactDirectionCategory.Later; + + default: + return null; + } +}; + +export const KeyFactorImpactDirectionLabel: FC = ({ + impact, + className, + option, + unit, +}) => { + const t = useTranslations(); + + const IMPACT_CONFIG = { + [ImpactDirectionCategory.Increase]: { + icon: , + textKey: "increasesLikelihood", + color: "text-olive-800 dark:text-olive-800-dark", + }, + [ImpactDirectionCategory.Decrease]: { + icon: , + textKey: "decreasesLikelihood", + color: "text-salmon-700 dark:text-salmon-700-dark", + }, + [ImpactDirectionCategory.More]: { + icon: , + textKey: "more", + color: "text-olive-800 dark:text-olive-800-dark", + }, + [ImpactDirectionCategory.Less]: { + icon: , + textKey: "less", + color: "text-salmon-700 dark:text-salmon-700-dark", + }, + [ImpactDirectionCategory.Earlier]: { + icon: , + textKey: "earlier", + color: "text-olive-800 dark:text-olive-800-dark", + }, + [ImpactDirectionCategory.Later]: { + icon: , + textKey: "later", + color: "text-salmon-700 dark:text-salmon-700-dark", + }, + [ImpactDirectionCategory.IncreaseUncertainty]: { + icon: ( +
+ + +
+ ), + textKey: "increasesUncertainty", + color: "text-blue-700 dark:text-blue-700-dark", + }, + } as const; + + const { icon, textKey, color } = IMPACT_CONFIG[impact]; + + return ( +
+ {icon} + + {t(textKey)} + {unit && <> {unit}} + {option && ( + <> +   + + {(tags) => + t.rich("forOption", { + ...tags, + option, + }) + } + + + )} + +
+ ); +}; + +const KeyFactorImpactDirectionContainer: FC = ({ + className, + isCompact, + ...props +}) => { + const t = useTranslations(); + + return ( +
+
+ {t("impact")} +
+ +
+ ); +}; + +export default KeyFactorImpactDirectionContainer; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_provider.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_provider.tsx new file mode 100644 index 0000000000..06db2ad163 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_provider.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import useHash from "@/hooks/use_hash"; + +type KeyFactorsContextValue = { + forceExpandedState?: boolean; + requestExpand: () => void; +}; + +const KeyFactorsContext = createContext( + undefined +); + +export const KeyFactorsProvider = ({ children }: PropsWithChildren) => { + const hash = useHash(); + const [forceExpandedState, setForceExpandedState] = useState(); + + // Expand immediately if URL hash points to key factors + useEffect(() => { + if (hash === "key-factors") { + setForceExpandedState(true); + } + }, [hash]); + + const requestExpand = useCallback(() => { + setForceExpandedState(true); + }, []); + + const value = useMemo( + () => ({ forceExpandedState, requestExpand }), + [forceExpandedState, requestExpand] + ); + + return ( + + {children} + + ); +}; + +export const useKeyFactorsContext = () => { + const ctx = useContext(KeyFactorsContext); + if (!ctx) { + throw new Error( + "useKeyFactorsContext must be used within KeyFactorsProvider" + ); + } + return ctx; +}; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_section.tsx index 227a524ae6..28423acbf8 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_section.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_section.tsx @@ -4,28 +4,28 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useTranslations } from "next-intl"; import posthog from "posthog-js"; -import { FC, useEffect, useMemo, useState } from "react"; +import { FC, useEffect, useState } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import AddKeyFactorsModal from "@/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal"; import DisplayCoherenceLink from "@/app/(main)/questions/components/coherence_links/display_coherence_link"; import Button from "@/components/ui/button"; +import ExpandableContent from "@/components/ui/expandable_content"; import SectionToggle from "@/components/ui/section_toggle"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; -import useHash from "@/hooks/use_hash"; import { FetchedAggregateCoherenceLink } from "@/types/coherence"; -import { Post, PostStatus } from "@/types/post"; +import { PostStatus, PostWithForecasts } from "@/types/post"; import { sendAnalyticsEvent } from "@/utils/analytics"; import cn from "@/utils/core/cn"; import { getKeyFactorsLimits } from "./hooks"; import KeyFactorItem from "./key_factor_item"; +import { useKeyFactorsContext } from "./key_factors_provider"; type KeyFactorsSectionProps = { - post: Post; - variant?: "default" | "compact"; + post: PostWithForecasts; }; const AddKeyFactorsButton: FC<{ @@ -50,20 +50,15 @@ const AddKeyFactorsButton: FC<{ ); }; -const KeyFactorsSection: FC = ({ - post, - variant = "default", -}) => { - const postId = post.id; +const KeyFactorsSection: FC = ({ post }) => { const postStatus = post.status; const t = useTranslations(); - const hash = useHash(); const { user } = useAuth(); const { setCurrentModal } = useModal(); - const [displayLimit, setDisplayLimit] = useState(4); const [isAddKeyFactorsModalOpen, setIsAddKeyFactorsModalOpen] = useState(false); const { aggregateCoherenceLinks } = useCoherenceLinksContext(); + const { forceExpandedState } = useKeyFactorsContext(); const { combinedKeyFactors } = useCommentsFeed(); @@ -71,22 +66,12 @@ const KeyFactorsSection: FC = ({ ? getKeyFactorsLimits(combinedKeyFactors, user?.id) : { factorsLimit: 0 }; - useEffect(() => { - // Expands the key factor list when you follow the #key-factors link. - if (hash === "key-factors") setDisplayLimit(combinedKeyFactors.length); - }, [hash, combinedKeyFactors.length]); - useEffect(() => { if (combinedKeyFactors.length > 0) { sendAnalyticsEvent("KeyFactorPageview"); } }, [combinedKeyFactors]); - const visibleKeyFactors = useMemo( - () => combinedKeyFactors.slice(0, displayLimit), - [combinedKeyFactors, displayLimit] - ); - if ( [ PostStatus.CLOSED, @@ -125,24 +110,22 @@ const KeyFactorsSection: FC = ({ const KeyFactors = combinedKeyFactors.length > 0 ? (
- {visibleKeyFactors.map((kf) => ( - - ))} - {combinedKeyFactors.length > displayLimit && ( -
- + +
+ {combinedKeyFactors.map((kf) => ( + + ))}
- )} +
) : (
@@ -174,50 +157,41 @@ const KeyFactorsSection: FC = ({ setIsAddKeyFactorsModalOpen(false)} - postId={postId} + post={post} user={user} /> )} - {variant === "compact" ? ( -
-

- {t("keyFactors")} -

- {KeyFactors} -
- ) : ( - - {KeyFactors} - {posthog.getFeatureFlag("aggregate_question_links") && - displayedAggregateLinks?.length > 0 && ( - <> -
- Aggregate Question Links -
- {Array.from( - displayedAggregateLinks, - (link: FetchedAggregateCoherenceLink) => ( -
- -

-
- ) - )} - - )} -
- )} + + {KeyFactors} + {posthog.getFeatureFlag("aggregate_question_links") && + displayedAggregateLinks?.length > 0 && ( + <> +
+ Aggregate Question Links +
+ {Array.from( + displayedAggregateLinks, + (link: FetchedAggregateCoherenceLink) => ( +
+ +

+
+ ) + )} + + )} +
); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/likehood_button.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/likehood_button.tsx new file mode 100644 index 0000000000..c3c5c2dc1e --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/likehood_button.tsx @@ -0,0 +1,65 @@ +import cn from "classnames"; +import { ButtonHTMLAttributes, FC, PropsWithChildren } from "react"; + +type Variant = "green" | "red" | "neutral"; + +type LikelihoodButtonProps = PropsWithChildren< + ButtonHTMLAttributes & { + selected?: boolean; + variant: Variant; + } +>; + +export const LikelihoodButton: FC = ({ + children, + selected, + variant, + className, + ...rest +}) => { + const variantClasses: Record = { + green: cn( + "border-mint-400 text-mint-700 hover:border-mint-700 hover:text-mint-800", + "active:border-mint-700 active:text-mint-800", + "data-[selected=true]:bg-mint-700 data-[selected=true]:text-gray-0 data-[selected=true]:border-mint-700", + "dark:border-mint-400-dark dark:text-mint-700-dark dark:hover:border-mint-700-dark dark:hover:text-mint-800-dark", + "dark:active:border-mint-700-dark dark:active:text-mint-800-dark", + "dark:data-[selected=true]:bg-mint-700-dark dark:data-[selected=true]:border-mint-700-dark dark:data-[selected=true]:text-gray-0-dark" + ), + + red: cn( + "border-salmon-300 text-salmon-700 hover:border-salmon-700 hover:text-salmon-800", + "active:border-salmon-700 active:text-salmon-800", + "data-[selected=true]:bg-salmon-700 data-[selected=true]:border-salmon-700 data-[selected=true]:text-gray-0", + "dark:border-salmon-300-dark dark:text-salmon-700-dark dark:hover:border-salmon-700-dark dark:hover:text-salmon-800-dark", + "dark:active:border-salmon-700-dark dark:active:text-salmon-800-dark", + "dark:data-[selected=true]:bg-salmon-700-dark dark:data-[selected=true]:border-salmon-700-dark dark:data-[selected=true]:text-gray-0-dark" + ), + + neutral: cn( + "border-blue-400 text-blue-700 hover:border-blue-700 hover:text-blue-800", + "active:border-blue-700 active:text-blue-800", + "data-[selected=true]:bg-blue-700 data-[selected=true]:border-blue-700 data-[selected=true]:text-gray-0", + "dark:border-blue-400-dark dark:text-blue-700-dark dark:hover:border-blue-700-dark dark:hover:text-blue-800-dark", + "dark:active:border-blue-700-dark dark:active:text-blue-800-dark", + "dark:data-[selected=true]:bg-blue-700-dark dark:data-[selected=true]:border-blue-700-dark dark:data-[selected=true]:text-gray-0-dark" + ), + }; + + return ( + + ); +}; + +export default LikelihoodButton; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx new file mode 100644 index 0000000000..14dc7495fa --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, useMemo } from "react"; + +import Listbox, { SelectOption } from "@/components/ui/listbox"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionWithForecasts } from "@/types/question"; +import cn from "@/utils/core/cn"; +import { + isGroupOfQuestionsPost, + isMultipleChoicePost, +} from "@/utils/questions/helpers"; + +export type Target = + | { kind: "whole" } + | { kind: "question"; question_id: number } + | { kind: "option"; question_id: number; question_option: string }; + +type Props = { + post: PostWithForecasts; + value: Target; + onChange: (t: Target) => void; + disabled?: boolean; + className?: string; +}; + +const OptionTargetPicker: FC = ({ + post, + value, + onChange, + disabled, + className, +}) => { + const t = useTranslations(); + + const isMC = isMultipleChoicePost(post); + const isGroup = isGroupOfQuestionsPost(post); + + const optionClassName = + "h-8 text-[13px] text-gray-800 dark:text-gray-800 text-left justify-start"; + const placeholder = isMC ? t("allOptions") : t("allSubquestions"); + const options: SelectOption[] = useMemo(() => { + if (isMC) { + const mcOptions = (post.question.options ?? []).map((opt) => ({ + value: opt, + label: opt, + className: optionClassName, + })); + return [ + { value: "", label: placeholder, className: optionClassName }, + ...mcOptions, + ]; + } + if (isGroup) { + const groupOptions = post.group_of_questions.questions.map( + (q: QuestionWithForecasts) => ({ + value: String(q.id), + label: q.label || q.title, + className: optionClassName, + }) + ); + return [ + { value: "", label: placeholder, className: optionClassName }, + ...groupOptions, + ]; + } + return []; + }, [isMC, isGroup, post, placeholder]); + + if (!isMC && !isGroup) return null; + + const selectedLabel = + value.kind === "option" + ? value.question_option + : value.kind === "question" + ? options.find((o) => o.value === String(value.question_id))?.label ?? + "" + : placeholder; + + const currentValue = + value.kind === "option" + ? value.question_option + : value.kind === "question" + ? String(value.question_id) + : ""; + + return ( +
+
+ {isMC + ? t("chooseOptionImpactedMost") + : t("chooseSubquestionImpactedMost")} +
+ + + options={options} + value={currentValue as string} + onChange={(v) => { + if (!v) return onChange({ kind: "whole" }); + if (isMC) + return onChange({ + kind: "option", + question_id: post.question.id, + question_option: v, + }); + return onChange({ kind: "question", question_id: Number(v) }); + }} + label={selectedLabel || placeholder} + buttonVariant="tertiary" + arrowPosition="right" + menuPosition="left" + disabled={disabled} + renderInPortal + preventParentScroll + menuMinWidthMatchesButton={false} + className="gap-1.5 rounded-[2px] p-1.5 text-xs leading-[12px]" + optionsClassName="mt-1.5" + /> +
+ ); +}; + +export default OptionTargetPicker; diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx index 15946a415b..b8121d852a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/consumer_question_layout/index.tsx @@ -32,6 +32,10 @@ const ConsumerQuestionLayout: React.FC> = ({ const t = useTranslations(); const hasTimeline = hasTimelineFn(postData); + const isFanGraph = + postData.group_of_questions?.graph_type === + GroupOfQuestionsGraphType.FanGraph; + return (
@@ -83,6 +87,8 @@ const ConsumerQuestionLayout: React.FC> = ({ @@ -91,6 +97,8 @@ const ConsumerQuestionLayout: React.FC> = ({
diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx index 8257ba9063..648aebd016 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/forecaster_question_layout/index.tsx @@ -25,6 +25,8 @@ const ForecasterQuestionLayout: React.FC> = ({ = ({ postData, preselectedGroupQuestionId, + showKeyFactors, + showTimeline, }) => { const t = useTranslations(); return (
+ {showTimeline && ( + + + + )} + {isConditionalPost(postData) && } - + {showKeyFactors && } diff --git a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx index ec871eb8d1..ca50f614d8 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_view/consumer_question_view/index.tsx @@ -1,5 +1,6 @@ import { useTranslations } from "next-intl"; +import KeyFactorsConsumerSection from "@/app/(main)/questions/[id]/components/key_factors/key_factors_consumer_section"; import ForecastersCounter from "@/app/(main)/questions/components/forecaster_counter"; import CommentStatus from "@/components/post_card/basic_post_card/comment_status"; import { @@ -19,8 +20,6 @@ import { import QuestionActionButton from "./action_buttons"; import ConsumerQuestionPrediction from "./prediction"; -import QuestionTimeline from "./timeline"; -import KeyFactorsSection from "../../key_factors/key_factors_section"; import QuestionTitle from "../shared/question_title"; type Props = { @@ -63,9 +62,7 @@ const ConsumerQuestionView: React.FC = ({ postData }) => { compact={false} />
- {postData.title} -
{showClosedMessageMultipleChoice && (

@@ -87,18 +84,12 @@ const ConsumerQuestionView: React.FC = ({ postData }) => {

- {postData.question && - [ - QuestionType.Numeric, - QuestionType.Discrete, - QuestionType.Date, - ].includes(postData.question.type) && ( -
- -
- )} - - {!isFanGraph && } + {postData.key_factors && postData.key_factors.length > 0 && ( + + )}
); diff --git a/front_end/src/app/(main)/questions/actions.ts b/front_end/src/app/(main)/questions/actions.ts index 1c69bf1434..281435924a 100644 --- a/front_end/src/app/(main)/questions/actions.ts +++ b/front_end/src/app/(main)/questions/actions.ts @@ -10,6 +10,7 @@ import { CreateCommentParams, EditCommentParams, KeyFactorVoteParams, + KeyFactorWritePayload, ToggleCMMCommentParams, VoteParams, } from "@/services/api/comments/comments.shared"; @@ -241,7 +242,7 @@ export async function createComment(commentData: CreateCommentParams) { export async function addKeyFactorsToComment( commentId: number, - keyFactors: string[] + keyFactors: KeyFactorWritePayload[] ) { try { return await ServerCommentsApi.addKeyFactorsToComment( diff --git a/front_end/src/app/(main)/questions/components/coherence_links/link_strength_component.tsx b/front_end/src/app/(main)/questions/components/coherence_links/link_strength_component.tsx index b8f0e5e9d7..786534c2bf 100644 --- a/front_end/src/app/(main)/questions/components/coherence_links/link_strength_component.tsx +++ b/front_end/src/app/(main)/questions/components/coherence_links/link_strength_component.tsx @@ -26,6 +26,12 @@ const colorAccent = { border-orange-400 dark:border-orange-400-dark`, } as const; +const strengthI18nKey: Record = { + [Strengths.Low]: "lowStrength", + [Strengths.Medium]: "mediumStrength", + [Strengths.High]: "highStrength", +}; + const LinkStrengthComponent: FC = ({ strength, disabled, @@ -35,7 +41,7 @@ const LinkStrengthComponent: FC = ({ const t = useTranslations(); const strengthLabel = convertStrengthNumberToLabel(strength); if (!strengthLabel) return null; - const label = t(strengthLabel); + const label = t(strengthI18nKey[strengthLabel]); const additionalStyling = colorAccent[strengthLabel]; return ( )} + {commentKeyFactors.length > 0 && canListKeyFactors && postData && ( + + )}
@@ -953,38 +963,41 @@ const Comment: FC = ({ isReplying={isReplying} /> )} - - {isKeyfactorsFormOpen && ( + {isKeyfactorsFormOpen && postData && ( k.trim() !== "") && - !suggestedKeyFactors.some((k) => k.selected)) + (!drafts.some((k) => k.driver.text.trim() !== "") && + !suggestedKeyFactors.some((k) => k.selected)) || + drafts.some( + (d) => + d.driver.text.trim() !== "" && + d.driver.impact_direction === null && + d.driver.certainty !== -1 + ) } > )} - {isCommentJustCreated && postData && ( )} - {comment.children?.length > 0 && !isCollapsed && ( = ({ withUserMentions initialMention={!initialMarkdown.trim() ? replyUsername : undefined} // only populate with mention if there is no draft withCodeBlocks + contentEditableClassName="text-base sm:text-inherit" /> )}
diff --git a/front_end/src/components/gradient-carousel.tsx b/front_end/src/components/gradient-carousel.tsx index 5a2c9c89f8..60141b362a 100644 --- a/front_end/src/components/gradient-carousel.tsx +++ b/front_end/src/components/gradient-carousel.tsx @@ -7,15 +7,18 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import cn from "@/utils/core/cn"; type SlideBy = { mode: "page" } | { mode: "items"; count: number }; +type CarouselNavState = { canPrev: boolean; canNext: boolean }; +type GradientVisibility = boolean | { left: boolean; right: boolean }; +type Resolver = boolean | ((state: CarouselNavState) => T); type Props = { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; itemClassName?: string; gapClassName?: string; slideBy?: SlideBy; - showGradients?: boolean; + showGradients?: Resolver; gradientFromClass?: string; - showArrows?: boolean; + showArrows?: Resolver; arrowClassName?: string; prevLabel?: string; nextLabel?: string; @@ -127,6 +130,27 @@ function ReusableGradientCarousel(props: Props) { [loop, slideBy] ); + const arrowsActive = + typeof showArrows === "function" + ? showArrows({ canPrev, canNext }) + : showArrows; + const arrowsEnabled = typeof showArrows === "function" ? true : showArrows; + + const gradients = + typeof showGradients === "function" + ? showGradients({ canPrev, canNext }) + : showGradients; + + let leftGradientVisible, rightGradientVisible; + + if (typeof gradients === "boolean") { + leftGradientVisible = gradients && canPrev; + rightGradientVisible = gradients && canNext; + } else { + leftGradientVisible = !!gradients?.left && canPrev; + rightGradientVisible = !!gradients?.right && canNext; + } + return (
(props: Props) {
- {showGradients && ( - <> - {canPrev && ( -
+ <> +
- {canNext && ( -
+
- )} + /> + - {showArrows && ( + {arrowsEnabled && ( <> - {canPrev && ( - - )} - - {canNext && ( - - )} + + + )}
diff --git a/front_end/src/components/ui/expandable_content.tsx b/front_end/src/components/ui/expandable_content.tsx index 664a41e95d..7dafaf344f 100644 --- a/front_end/src/components/ui/expandable_content.tsx +++ b/front_end/src/components/ui/expandable_content.tsx @@ -1,6 +1,7 @@ "use client"; import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { isNil } from "lodash"; import dynamic from "next/dynamic"; import { useTranslations } from "next-intl"; import { FC, PropsWithChildren, useEffect, useRef, useState } from "react"; @@ -15,6 +16,7 @@ type Props = { collapseLabel?: string; className?: string; gradientClassName?: string; + forceState?: boolean; }; const ExpandableContent: FC> = ({ @@ -23,6 +25,7 @@ const ExpandableContent: FC> = ({ maxCollapsedHeight = 128, gradientClassName = "from-blue-200 dark:from-blue-200-dark", className, + forceState, children, }) => { const t = useTranslations(); @@ -50,6 +53,14 @@ const ExpandableContent: FC> = ({ } }, [maxCollapsedHeight, height, width, ref]); + // Apply externally forced state and mark as user interaction so size effects won't override it. + useEffect(() => { + if (!isNil(forceState)) { + userInteractedRef.current = true; + setIsExpanded(forceState); + } + }, [forceState]); + return (
diff --git a/front_end/src/components/ui/listbox.tsx b/front_end/src/components/ui/listbox.tsx index 9443fdae63..cf97026ad6 100644 --- a/front_end/src/components/ui/listbox.tsx +++ b/front_end/src/components/ui/listbox.tsx @@ -1,12 +1,20 @@ -import { faChevronDown, faCheck } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Listbox as HeadlessListbox, ListboxButton, ListboxOption, ListboxOptions, + Portal, } from "@headlessui/react"; -import { Fragment, useMemo } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import Button, { ButtonVariant } from "@/components/ui/button"; import cn from "@/utils/core/cn"; @@ -22,7 +30,6 @@ type SingleSelectProps = { value: T; onChange: (option: T) => void; }; - type MultiSelectProps = { multiple: true; value: T[]; @@ -37,7 +44,11 @@ type Props = { menuPosition?: "left" | "right"; label?: string; className?: string; + optionsClassName?: string; disabled?: boolean; + renderInPortal?: boolean; + preventParentScroll?: boolean; + menuMinWidthMatchesButton?: boolean; } & (SingleSelectProps | MultiSelectProps); const Listbox = (props: Props) => { @@ -50,81 +61,226 @@ const Listbox = (props: Props) => { label, className, disabled, + optionsClassName, + renderInPortal = false, + preventParentScroll = false, + menuMinWidthMatchesButton = true, } = props; - const activeOptionLabel = useMemo( - () => - props.multiple - ? "Select options" - : options.find((o) => o.value === props.value)?.label ?? "", - [options, props.multiple, props.value] - ); + const buttonRef = useRef(null); + + const activeLabel = useMemo(() => { + if (isMulti(props)) return "Select options"; + const v = isSingle(props) ? props.value : undefined; + return options.find((o) => o.value === v)?.label ?? ""; + }, [options, props]); - // Handle selection change const handleChange = (value: T | T[]) => { - if (props.multiple) { - props.onChange(value as T[]); - } else { - props.onChange(value as T); - } + if (isMulti(props)) props.onChange(value as T[]); + else (props as SingleSelectProps).onChange(value as T); }; return ( ).value + } onChange={handleChange} - multiple={props.multiple} + multiple={isMulti(props)} className="relative text-gray-900 dark:text-gray-900-dark" disabled={disabled} > - - - {label ?? activeOptionLabel} - - - {options.map((option) => ( - - {({ focus, selected }) => ( - - )} - - ))} - + {({ open }) => ( + <> + + + {label ?? activeLabel} + + + {!renderInPortal && ( + + {options.map((option) => ( + + {({ focus, selected }) => ( + + )} + + ))} + + )} + + {renderInPortal && ( + + open={open} + renderInPortal + preventParentScroll={preventParentScroll} + menuMinWidthMatchesButton={menuMinWidthMatchesButton} + optionsClassName={optionsClassName} + buttonRef={buttonRef} + options={options} + onClick={onClick} + multiple={isMulti(props)} + /> + )} + + )} ); }; +function isMulti(p: Props): p is Props & MultiSelectProps { + return (p as { multiple?: boolean }).multiple === true; +} + +function isSingle(p: Props): p is Props & SingleSelectProps { + return !isMulti(p); +} + +type FloatingMenuProps = { + open: boolean; + renderInPortal: boolean; + preventParentScroll: boolean; + menuMinWidthMatchesButton: boolean; + optionsClassName?: string; + buttonRef: React.RefObject; + options: SelectOption[]; + onClick?: (value: string) => void; + multiple: boolean; +}; + +function FloatingMenu({ + open, + renderInPortal, + preventParentScroll, + menuMinWidthMatchesButton, + optionsClassName, + buttonRef, + options, + onClick, + multiple, +}: FloatingMenuProps) { + const [rect, setRect] = useState(null); + const [flipUp, setFlipUp] = useState(false); + + const updateRect = useCallback(() => { + if (!buttonRef.current) return; + const r = buttonRef.current.getBoundingClientRect(); + setRect(r); + const spaceBelow = window.innerHeight - r.bottom; + const spaceAbove = r.top; + setFlipUp(spaceBelow < 260 && spaceAbove > spaceBelow); + }, [buttonRef]); + + useEffect(() => { + if (!renderInPortal || !open) return; + updateRect(); + const onResizeOrScroll = () => updateRect(); + window.addEventListener("resize", onResizeOrScroll, { passive: true }); + window.addEventListener("scroll", onResizeOrScroll, { passive: true }); + return () => { + window.removeEventListener("resize", onResizeOrScroll); + window.removeEventListener("scroll", onResizeOrScroll); + }; + }, [open, renderInPortal, updateRect]); + + const menu = ( + { + if (renderInPortal && preventParentScroll) e.stopPropagation(); + }} + onTouchMoveCapture={(e) => { + if (renderInPortal && preventParentScroll) e.stopPropagation(); + }} + > + {options.map((option) => ( + + {({ focus, selected }) => ( + + )} + + ))} + + ); + + if (!renderInPortal) return menu; + if (!open || !rect) return null; + return {menu}; +} + export default Listbox; diff --git a/front_end/src/services/api/comments/comments.server.ts b/front_end/src/services/api/comments/comments.server.ts index 40a12fa6e0..ca423a5516 100644 --- a/front_end/src/services/api/comments/comments.server.ts +++ b/front_end/src/services/api/comments/comments.server.ts @@ -7,6 +7,7 @@ import CommentsApi, { CreateCommentParams, EditCommentParams, KeyFactorVoteParams, + KeyFactorWritePayload, ToggleCMMCommentParams, VoteParams, } from "./comments.shared"; @@ -34,13 +35,11 @@ class ServerCommentsApiClass extends CommentsApi { async addKeyFactorsToComment( commentId: number, - keyFactors: string[] + keyFactors: KeyFactorWritePayload[] ): Promise { - return await this.post( + return await this.post( `/comments/${commentId}/add-key-factors/`, - { - key_factors: keyFactors, - } + keyFactors ); } diff --git a/front_end/src/services/api/comments/comments.shared.ts b/front_end/src/services/api/comments/comments.shared.ts index 69fe69767d..57d389bcbe 100644 --- a/front_end/src/services/api/comments/comments.shared.ts +++ b/front_end/src/services/api/comments/comments.shared.ts @@ -3,6 +3,7 @@ import { CommentType, KeyFactorVoteType, CommentOfWeekEntry, + Driver, } from "@/types/comment"; import { encodeQueryParams } from "@/utils/navigation"; @@ -18,13 +19,19 @@ export type getCommentsParams = { is_private?: boolean; }; +export type KeyFactorWritePayload = { + question_id?: number; + question_option?: string; + driver: Driver; +}; + export type CreateCommentParams = { parent?: number; text: string; on_post?: number; included_forecast?: boolean; is_private: boolean; - key_factors?: string[]; + key_factors?: KeyFactorWritePayload[]; }; export type EditCommentParams = { diff --git a/front_end/src/types/comment.ts b/front_end/src/types/comment.ts index 588213ff3d..7e86090245 100644 --- a/front_end/src/types/comment.ts +++ b/front_end/src/types/comment.ts @@ -63,24 +63,22 @@ export type ForecastType = { }; export enum KeyFactorVoteTypes { - UP_DOWN = "a_updown", - TWO_STEP = "b_2step", - LIKERT = "c_likert", + STRENGTH = "strength", } -export enum ImpactValues { - LOW = 2, - MEDIUM = 3, +export enum StrengthValues { + LOW = 1, + MEDIUM = 2, HIGH = 5, - LOW_NEGATIVE = -2, - MEDIUM_NEGATIVE = -3, - HIGH_NEGATIVE = -5, NO_IMPACT = 0, } export type KeyFactorVoteType = (typeof KeyFactorVoteTypes)[keyof typeof KeyFactorVoteTypes]; +export type StrengthVoteOption = 0 | 1 | 2 | 5; + +// TODO: drop Legacy AB-test scores type KeyFactorVoteA = -1 | 1 | null; type KeyFactorVoteBAndC = -5 | -3 | -2 | 0 | 2 | 3 | 5; export type KeyFactorVoteScore = KeyFactorVoteA | KeyFactorVoteBAndC; @@ -92,14 +90,22 @@ export type KeyFactorVote = { second_step_completed?: boolean; // used only for two step survey }; -export enum ImpactDirection { - Increase = "increase", - Decrease = "decrease", +export enum ImpactDirectionCategory { + Increase, + Decrease, + More, + Less, + Earlier, + Later, + IncreaseUncertainty, } -export type Driver = { +export type ImpactMetadata = + | { impact_direction: 1 | -1; certainty: null } + | { impact_direction: null; certainty: -1 }; + +export type Driver = ImpactMetadata & { text: string; - impact_direction: ImpactDirection; }; export type KeyFactor = { @@ -107,13 +113,26 @@ export type KeyFactor = { driver: Driver; author: AuthorType; // used to set limit per question comment_id: number; - user_votes: KeyFactorVote[]; // empty array if the user has not voted - vote_type: KeyFactorVoteType | null; // null if the user has not voted - votes_score: number; - votes_count: number; + vote: KeyFactorVoteAggregate; + post_id?: number; + question_id?: number | null; + question?: { + id: number; + label: string; + unit?: string | null; + } | null; + question_option?: string; + freshness?: number; }; -export type DraftKind = "create" | "edit"; +export type KeyFactorVoteAggregate = { + // Aggregated strength score + score: number; + // Current user's vote + user_vote: StrengthVoteOption | null; + // Total number of votes + count: number; +}; type DraftBase = { markdown: string; diff --git a/front_end/src/types/key_factors.ts b/front_end/src/types/key_factors.ts new file mode 100644 index 0000000000..3ffd72db07 --- /dev/null +++ b/front_end/src/types/key_factors.ts @@ -0,0 +1,13 @@ +export type KeyFactorDraft = ( + | { kind: "whole" } + | { kind: "question"; question_id: number } + | { kind: "option"; question_id: number; question_option: string } +) & { + driver: { + text: string; + impact_direction: 1 | -1 | null; + certainty: -1 | null; + }; +}; + + diff --git a/front_end/src/utils/questions/helpers.ts b/front_end/src/utils/questions/helpers.ts index 20f5096797..3cf330f326 100644 --- a/front_end/src/utils/questions/helpers.ts +++ b/front_end/src/utils/questions/helpers.ts @@ -202,3 +202,21 @@ export function isValidScaling( !isNil(scaling) && !isNil(scaling.range_min) && !isNil(scaling.range_max) ); } + +/** + * Returns the effective QuestionType for a post: + * - Single question: the question's actual type + * - Group of questions: an inferred group type (Numeric for fan graph, Date otherwise) + * - Conditional: type of condition child + * - Notebook: null + */ +export function inferEffectiveQuestionTypeFromPost( + post: PostWithForecasts +): QuestionType | null { + if (isQuestionPost(post)) return post.question.type; + if (isGroupOfQuestionsPost(post)) + return post.group_of_questions.questions.at(0)?.type || null; + if (isConditionalPost(post)) return post.conditional.condition_child.type; + + return null; +} From 8601488d6091170656d938a9ac7551b770ddc0d5 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 23 Oct 2025 17:19:18 +0200 Subject: [PATCH 21/23] Simplified `KeyFactorDraft` --- .../key_factors/add_key_factors_modal.tsx | 3 -- .../add_modal/driver_creation_form.tsx | 41 +++++-------------- .../[id]/components/key_factors/hooks.ts | 8 ++-- .../key_factors/option_target_picker.tsx | 37 +++++++++-------- .../src/components/comment_feed/comment.tsx | 3 -- front_end/src/types/key_factors.ts | 10 ++--- 6 files changed, 37 insertions(+), 65 deletions(-) diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx index 8c098114c7..2b8f438f76 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx @@ -137,7 +137,6 @@ export const AddKeyFactorsForm = ({ setDrafts([ ...drafts, { - kind: "whole", driver: { text: "", impact_direction: null, certainty: null }, }, ]); @@ -181,7 +180,6 @@ const AddKeyFactorsModal: FC = ({ const [markdown, setMarkdown] = useState(""); const [drafts, setDrafts] = useState([ { - kind: "whole", driver: { text: "", impact_direction: null, certainty: null }, }, ]); @@ -207,7 +205,6 @@ const AddKeyFactorsModal: FC = ({ const resetAll = () => { setDrafts([ { - kind: "whole", driver: { text: "", impact_direction: null, certainty: null }, }, ]); diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx index 6636d8a65b..e86d6d64a2 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx @@ -37,7 +37,7 @@ const DriverCreationForm: FC = ({ let questionType = questionTypeBase; let effectiveUnit = isQuestionPost(post) ? post.question.unit : undefined; - if (isGroupOfQuestionsPost(post) && draft.kind === "question") { + if (isGroupOfQuestionsPost(post) && draft.question_id) { const sq = post.group_of_questions.questions.find( (q) => q.id === draft.question_id ); @@ -97,37 +97,16 @@ const DriverCreationForm: FC = ({ )} - setDraft( - t.kind === "whole" - ? ({ kind: "whole", driver: draft.driver } as KeyFactorDraft) - : t.kind === "question" - ? ({ - kind: "question", - question_id: t.question_id, - driver: draft.driver, - } as KeyFactorDraft) - : ({ - kind: "option", - question_id: t.question_id, - question_option: t.question_option, - driver: draft.driver, - } as KeyFactorDraft) - ) + setDraft({ + driver: draft.driver, + question_id: t.question_id, + question_option: t.question_option, + }) } />
diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts index 129edf98f5..43c7dedb54 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts @@ -51,14 +51,16 @@ export const useKeyFactors = ({ draft: KeyFactorDraft, payload: KeyFactorWritePayload ): KeyFactorWritePayload => { - if (draft.kind === "question") - return { ...payload, question_id: draft.question_id }; - if (draft.kind === "option") + if (draft.question_option) { return { ...payload, question_id: draft.question_id, question_option: draft.question_option, }; + } + if (draft.question_id) { + return { ...payload, question_id: draft.question_id }; + } return payload; }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx index 14dc7495fa..62925c8bfe 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/option_target_picker.tsx @@ -12,10 +12,10 @@ import { isMultipleChoicePost, } from "@/utils/questions/helpers"; -export type Target = - | { kind: "whole" } - | { kind: "question"; question_id: number } - | { kind: "option"; question_id: number; question_option: string }; +export type Target = { + question_id?: number; + question_option?: string; +}; type Props = { post: PostWithForecasts; @@ -38,7 +38,7 @@ const OptionTargetPicker: FC = ({ const isGroup = isGroupOfQuestionsPost(post); const optionClassName = - "h-8 text-[13px] text-gray-800 dark:text-gray-800 text-left justify-start"; + "h-8 text-[13px] text-gray-800 dark:text-gray-800-dark text-left justify-start"; const placeholder = isMC ? t("allOptions") : t("allSubquestions"); const options: SelectOption[] = useMemo(() => { if (isMC) { @@ -70,20 +70,22 @@ const OptionTargetPicker: FC = ({ if (!isMC && !isGroup) return null; - const selectedLabel = - value.kind === "option" - ? value.question_option - : value.kind === "question" + const selectedLabel = isMC + ? value.question_option || placeholder + : isGroup + ? value.question_id ? options.find((o) => o.value === String(value.question_id))?.label ?? "" - : placeholder; + : placeholder + : placeholder; - const currentValue = - value.kind === "option" - ? value.question_option - : value.kind === "question" + const currentValue = isMC + ? value.question_option ?? "" + : isGroup + ? value.question_id ? String(value.question_id) - : ""; + : "" + : ""; return (
@@ -97,14 +99,13 @@ const OptionTargetPicker: FC = ({ options={options} value={currentValue as string} onChange={(v) => { - if (!v) return onChange({ kind: "whole" }); + if (!v) return onChange({}); if (isMC) return onChange({ - kind: "option", question_id: post.question.id, question_option: v, }); - return onChange({ kind: "question", question_id: Number(v) }); + return onChange({ question_id: Number(v) }); }} label={selectedLabel || placeholder} buttonVariant="tertiary" diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 12795ac963..792e929223 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -236,7 +236,6 @@ const Comment: FC = ({ const [isReplying, setIsReplying] = useState(false); const [drafts, setDrafts] = useState([ { - kind: "whole", driver: { text: "", impact_direction: null, certainty: null }, }, ]); @@ -354,7 +353,6 @@ const Comment: FC = ({ clearState(); setDrafts([ { - kind: "whole", driver: { text: "", impact_direction: null, certainty: null }, }, ]); @@ -414,7 +412,6 @@ const Comment: FC = ({ clearState(); setDrafts([ { - kind: "whole", driver: { text: "", impact_direction: null, certainty: null }, }, ]); diff --git a/front_end/src/types/key_factors.ts b/front_end/src/types/key_factors.ts index 3ffd72db07..fa07d9e5f2 100644 --- a/front_end/src/types/key_factors.ts +++ b/front_end/src/types/key_factors.ts @@ -1,13 +1,9 @@ -export type KeyFactorDraft = ( - | { kind: "whole" } - | { kind: "question"; question_id: number } - | { kind: "option"; question_id: number; question_option: string } -) & { +export type KeyFactorDraft = { + question_id?: number; + question_option?: string; driver: { text: string; impact_direction: 1 | -1 | null; certainty: -1 | null; }; }; - - From 924a3edbb49f28deed341153af1cf18b07c2898e Mon Sep 17 00:00:00 2001 From: Hlib Date: Fri, 24 Oct 2025 14:24:44 +0200 Subject: [PATCH 22/23] Adjust ai suggested key factors (#3669) * New AI-KF suggestions core * Refactored suggestions * Simplified `KeyFactorDraft` * Small fix * Adapted suggested keyFactors to the new payload * Small fix * Small adjustments * Small fix * Small fix * Small fix * Adjusted copy * Small adjustment * Small adjustment * Enabled Group/MultipleChoice auto suggestions --- comments/serializers/key_factors.py | 4 +- comments/services/key_factors/__init__.py | 0 .../{key_factors.py => key_factors/common.py} | 26 -- comments/services/key_factors/suggestions.py | 296 ++++++++++++++++++ comments/views/common.py | 2 +- comments/views/key_factors.py | 23 +- front_end/messages/cs.json | 5 +- front_end/messages/en.json | 5 +- front_end/messages/es.json | 5 +- front_end/messages/pt.json | 5 +- front_end/messages/zh-TW.json | 5 +- front_end/messages/zh.json | 5 +- .../key_factors/add_key_factors_modal.tsx | 127 +++++--- .../add_modal/driver_creation_form.tsx | 2 +- .../[id]/components/key_factors/hooks.ts | 37 ++- .../key_factors/key_factors_carousel.tsx | 9 +- .../src/components/comment_feed/comment.tsx | 3 +- .../src/components/comment_feed/index.tsx | 11 +- .../services/api/comments/comments.shared.ts | 6 +- front_end/src/types/comment.ts | 7 +- tests/unit/test_comments/test_services.py | 2 +- utils/openai.py | 68 +--- 22 files changed, 458 insertions(+), 195 deletions(-) create mode 100644 comments/services/key_factors/__init__.py rename comments/services/{key_factors.py => key_factors/common.py} (89%) create mode 100644 comments/services/key_factors/suggestions.py diff --git a/comments/serializers/key_factors.py b/comments/serializers/key_factors.py index fd096eb61f..8f0c34a3cb 100644 --- a/comments/serializers/key_factors.py +++ b/comments/serializers/key_factors.py @@ -5,7 +5,7 @@ from rest_framework.exceptions import ValidationError from comments.models import KeyFactor, KeyFactorDriver, ImpactDirection, KeyFactorVote -from comments.services.key_factors import ( +from comments.services.key_factors.common import ( get_votes_for_key_factors, calculate_key_factors_freshness, ) @@ -120,7 +120,7 @@ def validate(self, attrs): class KeyFactorWriteSerializer(serializers.ModelSerializer): driver = KeyFactorDriverSerializer(required=False) - question_id = serializers.IntegerField(required=False) + question_id = serializers.IntegerField(required=False, allow_null=True) class Meta: model = KeyFactor diff --git a/comments/services/key_factors/__init__.py b/comments/services/key_factors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/comments/services/key_factors.py b/comments/services/key_factors/common.py similarity index 89% rename from comments/services/key_factors.py rename to comments/services/key_factors/common.py index 4b1866394e..04057c32ef 100644 --- a/comments/services/key_factors.py +++ b/comments/services/key_factors/common.py @@ -13,13 +13,11 @@ KeyFactorDriver, ImpactDirection, ) -from posts.models import Post from posts.services.common import get_post_permission_for_user from projects.permissions import ObjectPermission from questions.models import Question from users.models import User from utils.datetime import timedelta_to_days -from utils.openai import generate_keyfactors @transaction.atomic @@ -93,30 +91,6 @@ def create_key_factors(comment: Comment, key_factors: list[dict]): ) -def generate_keyfactors_for_comment( - comment_text: str, existing_keyfactors: list[str], post: Post -): - if post.question is None and post.group_of_questions is None: - raise ValidationError( - "Key factors can only be generated for questions and question groups" - ) - - if post.question: - question_data = ( - f"Title: {post.title}\n Description: {post.question.description}" - ) - elif post.group_of_questions: - question_data = ( - f"Title: {post.title}\n Description: {post.group_of_questions.description}" - ) - - return generate_keyfactors( - question_data, - comment_text, - existing_keyfactors, - ) - - @transaction.atomic def create_key_factor( *, diff --git a/comments/services/key_factors/suggestions.py b/comments/services/key_factors/suggestions.py new file mode 100644 index 0000000000..58b8497c4e --- /dev/null +++ b/comments/services/key_factors/suggestions.py @@ -0,0 +1,296 @@ +import json +import textwrap +from typing import List, Optional + +from django.conf import settings +from pydantic import BaseModel, Field, ValidationError, model_validator + +from comments.models import KeyFactor, KeyFactorDriver +from posts.models import Post +from questions.models import Question +from utils.openai import pydantic_to_openai_json_schema, get_openai_client + +# Central constraints +MAX_LENGTH = 50 + + +# TODO: unit tests! +class KeyFactorResponse(BaseModel): + text: str = Field( + ..., description="Concise single-sentence key factor (<= 50 chars)" + ) + impact_direction: Optional[int] = Field( + None, + description="Set to 1 or -1 to indicate direction; omit if certainty is set", + ) + certainty: Optional[int] = Field( + None, + description="Set to -1 only if the factor increases uncertainty; else omit", + ) + option: Optional[str] = Field( + None, + description="For multiple choice or group questions, which option/subquestion this factor relates to", + ) + + @model_validator(mode="before") + @classmethod + def normalize_fields(cls, data): + if not isinstance(data, dict): + return data + + def coerce(value, allowed): + try: + v = int(value) + return v if v in allowed else None + except (TypeError, ValueError): + return None + + impact_direction = coerce(data.get("impact_direction"), {1, -1}) + certainty = coerce(data.get("certainty"), {-1}) + + # Enforce XOR preference: certainty (-1) overrides impact_direction + if certainty == -1: + impact_direction = None + + data.update(impact_direction=impact_direction, certainty=certainty) + + return data + + +class KeyFactorsResponse(BaseModel): + key_factors: List[KeyFactorResponse] + + +def _convert_llm_response_to_key_factor( + post: Post, response: KeyFactorResponse +) -> KeyFactor: + """ + Generating and normalizing KeyFactor object (but not saving, just for the structure) from LLM payload + """ + + option = response.option.lower() if response.option else None + question_id = None + question_option = None + + if option: + if ( + post.question + and post.question.type == Question.QuestionType.MULTIPLE_CHOICE + ): + question_id = post.question_id + question_option = next( + (x for x in post.question.options if x.lower() == option), + None, + ) + + if post.group_of_questions: + question_id = next( + (q.id for q in post.get_questions() if q.label.lower() == option), None + ) + + return KeyFactor( + question_id=question_id, + question_option=question_option, + driver=KeyFactorDriver( + text=response.text, + certainty=response.certainty, + impact_direction=response.impact_direction, + ), + ) + + +def build_post_question_summary(post: Post) -> tuple[str, Question.QuestionType]: + """ + Build a compact text summary for a `Post` to provide to the LLM and + determine the effective question type for impact rules. + """ + questions = post.get_questions() + post_type = questions[0].type if questions else Question.QuestionType.BINARY + + summary_lines = [ + f"Title: {post.title}", + f"Type: {post_type}", + ] + + if post.question: + summary_lines.append(f"Description: {post.question.description}") + if post_type == Question.QuestionType.MULTIPLE_CHOICE: + summary_lines.append(f"Options: {post.question.options}") + elif post.group_of_questions_id: + summary_lines += [ + f"Description: {post.group_of_questions.description}", + f"Options: {[q.label for q in questions]}", + ] + + return "\n".join(summary_lines), post_type + + +def get_impact_type_instructions( + question_type: Question.QuestionType, is_group: bool +) -> str: + instructions = f""" + - Set impact_direction (required): 1 or -1. + - 1 means this factor makes the event more likely. + - -1 means it makes the event less likely. + """ + + if question_type == Question.QuestionType.NUMERIC: + instructions = f""" + - For each key factor, set exactly one of these fields: + - impact_direction: 1 or -1 + - certainty: -1 + - Use certainty = -1 only if the factor increases uncertainty about the forecast. + - If using impact_direction: + - 1 pushes the predicted value higher. + - -1 pushes the predicted value lower. + """ + + if question_type == Question.QuestionType.DATE: + instructions = f""" + - For each key factor, set exactly one of these fields: + - impact_direction: 1 or -1 + - certainty: -1 + - Use certainty = -1 only if the factor increases uncertainty about the timing. + - If using impact_direction: + - 1 means the event is expected later. + - -1 means the event is expected earlier. + """ + + if is_group or question_type == Question.QuestionType.MULTIPLE_CHOICE: + instructions += f""" + - Add an optional "option" field if the key factor specifically supports one answer option over others. + - If it affects all options, omit the "option" field. + """ + + return instructions + + +def generate_keyfactors( + *, + question_summary: str, + comment: str, + existing_key_factors: list[dict], + type_instructions: str, +) -> list[KeyFactorResponse]: + """ + Generate key factors based on question type and comment. + """ + + system_prompt = textwrap.dedent( + """ + You are a helpful assistant that creates tools for forecasters to better forecast on Metaculus, + where users can predict on all sorts of questions about real-world events. + """ + ) + + user_prompt = textwrap.dedent( + f""" + You are a helpful assistant that generates a list of up to 3 key factors for a comment + that a user makes on a Metaculus question. + + The comment is intended to describe what might influence the predictions on the question so the + key factors should only be relate to that. + The key factors should be the most important things that the user is trying to say + in the comment and how it might influence the predictions on the question. + The key factors text should be single sentences, not longer than {MAX_LENGTH} characters + and they should only contain the key factor, no other text (e.g.: do not reference the user). + + Each key factor should describe something that could influence the forecast for the question. + Also specify the direction of impact as described below. + + {type_instructions} + + Output rules: + - Return valid JSON only, matching the schema. + - Each key factor is under {MAX_LENGTH} characters. + - Do not include any key factors that are already in the existing key factors list. Read that carefully and make sure you don't have any duplicates. + - Be conservative and only include clearly relevant factors. + - Do not include any formatting like quotes, numbering or other punctuation + - If the comment provides no meaningful forecasting insight, return the literal string "None". + + The question details are: + + {question_summary} + + + The user comment is: + + + {comment} + + + The existing key factors are: + + + {existing_key_factors} + + """ + ) + + client = get_openai_client(settings.OPENAI_API_KEY_FACTORS) + + response_format = pydantic_to_openai_json_schema(KeyFactorsResponse) + + try: + response = client.chat.completions.create( + # TODO: update to 5 + model="gpt-4o", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format=response_format, + ) + except Exception: + return [] + + if not response.choices: + return [] + + content = response.choices[0].message.content + + if content is None or content.lower() == "none": + return [] + + try: + data = json.loads(content) + # TODO: replace KeyFactorsResponse with plain list + parsed = KeyFactorsResponse(**data) + return parsed.key_factors + except (json.JSONDecodeError, ValidationError): + return [] + + +def _serialize_key_factor(kf: KeyFactor): + option = kf.question.label if kf.question else kf.question_option + + if kf.driver_id: + return { + "text": kf.driver.text, + "impact_direction": kf.driver.impact_direction, + "certainty": kf.driver.certainty, + "option": option or None, + } + + +def generate_key_factors_for_comment( + comment_text: str, existing_key_factors: list[KeyFactor], post: Post +): + if post.question is None and post.group_of_questions is None: + raise ValidationError( + "Key factors can only be generated for questions and question groups" + ) + + serialized_question_summary, post_type = build_post_question_summary(post) + serialized_key_factors = [_serialize_key_factor(kf) for kf in existing_key_factors] + + response = generate_keyfactors( + question_summary=serialized_question_summary, + comment=comment_text, + existing_key_factors=serialized_key_factors, + type_instructions=get_impact_type_instructions( + post_type, bool(post.group_of_questions_id) + ), + ) + + return [_convert_llm_response_to_key_factor(post, kf) for kf in response] diff --git a/comments/views/common.py b/comments/views/common.py index f14bb16244..b265996244 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -33,7 +33,7 @@ update_comment, ) from comments.services.feed import get_comments_feed -from comments.services.key_factors import create_key_factors +from comments.services.key_factors.common import create_key_factors from notifications.services import send_comment_report_notification_to_staff from posts.services.common import get_post_permission_for_user from projects.permissions import ObjectPermission diff --git a/comments/views/key_factors.py b/comments/views/key_factors.py index 05a0c4c826..3efd3f10fb 100644 --- a/comments/views/key_factors.py +++ b/comments/views/key_factors.py @@ -17,12 +17,12 @@ KeyFactorWriteSerializer, serialize_key_factor_votes, ) -from comments.services.key_factors import ( +from comments.services.key_factors.common import ( create_key_factors, - generate_keyfactors_for_comment, key_factor_vote, delete_key_factor, ) +from comments.services.key_factors.suggestions import generate_key_factors_for_comment from posts.services.common import get_post_permission_for_user from projects.permissions import ObjectPermission @@ -74,25 +74,20 @@ def comment_add_key_factors_view(request: Request, pk: int): def comment_suggested_key_factors_view(request: Request, pk: int): comment = get_object_or_404(Comment, pk=pk) - existing_keyfactors = [ - keyfactor.driver.text - for keyfactor in KeyFactor.objects.for_posts([comment.on_post]) + existing_keyfactors = ( + KeyFactor.objects.for_posts([comment.on_post]) .filter_active() .filter(driver__isnull=False) .select_related("driver") - ] - - suggested_key_factors = generate_keyfactors_for_comment( - comment.text, - existing_keyfactors, - comment.on_post, # type: ignore (on_post is not None) ) - return Response( - suggested_key_factors, - status=status.HTTP_200_OK, + suggested_key_factors = generate_key_factors_for_comment( + comment.text, existing_keyfactors, comment.on_post ) + # TODO: check N+1 query + return Response(KeyFactorWriteSerializer(suggested_key_factors, many=True).data) + @api_view(["DELETE"]) def key_factor_delete(request: Request, pk: int): diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index b31efcafab..7a87c86191 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1547,11 +1547,12 @@ "medium": "střední", "topKeyFactors": "Hlavní klíčové faktory", "addDriver": "Přidat řidiče", - "addDriverModalDescription": "Zadejte své řidiče níže - snažte se je udržet stručné a jasné.", - "addDriverModalCommentDescription": "Řidiče by měly být podloženy komentáři - zadejte níže svůj komentář a vysvětlete své důvody.", "allOptions": "Všechny možnosti", "allSubquestions": "Všechny podotázky", "chooseOptionImpactedMost": "Vyberte možnost, kterou tento řidič ovlivňuje nejvíce:", "chooseSubquestionImpactedMost": "Vyberte podotázku, kterou tento řidič ovlivňuje nejvíce:", + "addDriverModalDescription": "Drivery jsou významná fakta, principy nebo hypotetické situace. Například: „MMLU benchmark se začal nasycovat koncem roku 2024“, „Čína by mohla napadnout Tchaj-wan“, „Dynamika vítěz bere vše“.", + "addDriverModalCommentDescription": "Drivery by měly být podepřeny zdroji a argumenty. Napište komentář níže a vysvětlete své zdůvodnění.", + "addDriverCommentDisclaimer": "Ujistěte se, že vaše drivery jsou podepřeny zdroji a zdůvodněním ve vašem komentáři.", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 14ab2da7cf..e82449a27a 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1258,8 +1258,9 @@ "addDriver": "Add Driver", "addKeyFactors": "Add Key Factors", "addKeyFactorsModalP1": "List the key factors you think matter most — keep each one short and specific.", - "addDriverModalDescription": "Enter your drivers below - try to keep them short and clear.", - "addDriverModalCommentDescription": "Drivers should be backed up by comments - enter a comment below and explain your reasoning.", + "addDriverModalDescription": "Drivers are impactful facts, principles, or hypotheticals. For example: “The MMLU benchmark started saturating in late 2024”, “China could invade Taiwan”, “Winner-take-all dynamics”.", + "addDriverModalCommentDescription": "Drivers should be backed up by sources and arguments. Write a comment below and explain your reasoning.", + "addDriverCommentDisclaimer": "Make sure your drivers are backed up by sources and reasoning in your comment.", "addKeyFactorsModalP2": "Please post a comment to explain why these factors matter for your forecast.", "typeKeyFator": "Type a key factor here", "driverInputPlaceholder": "Type your driver here", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 17b7c0deec..5273e7cf45 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1547,11 +1547,12 @@ "medium": "medio", "topKeyFactors": "Factores Clave Principales", "addDriver": "Agregar conductor", - "addDriverModalDescription": "Introduce tus conductores a continuación - trata de mantenerlos breves y claros.", - "addDriverModalCommentDescription": "Los conductores deben estar respaldados por comentarios - introduce un comentario a continuación y explica tu razonamiento.", "allOptions": "Todas las opciones", "allSubquestions": "Todas las subpreguntas", "chooseOptionImpactedMost": "Elige una opción que este conductor impacte más:", "chooseSubquestionImpactedMost": "Elige una subpregunta que este conductor impacte más:", + "addDriverModalDescription": "Los impulsores son hechos, principios o hipótesis impactantes. Por ejemplo: “El benchmark MMLU comenzó a saturarse a finales de 2024”, “China podría invadir Taiwán”, “Dinámicas de ganador se lleva todo”.", + "addDriverModalCommentDescription": "Los impulsores deben estar respaldados por fuentes y argumentos. Escribe un comentario a continuación y explica tu razonamiento.", + "addDriverCommentDisclaimer": "Asegúrate de que tus impulsores estén respaldados por fuentes y razonamiento en tu comentario.", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 08b69865ad..97802c9236 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1545,11 +1545,12 @@ "medium": "médio", "topKeyFactors": "Principais Fatores Chave", "addDriver": "Adicionar Driver", - "addDriverModalDescription": "Digite seus drivers abaixo - tente mantê-los curtos e claros.", - "addDriverModalCommentDescription": "Os drivers devem ser apoiados por comentários - insira um comentário abaixo e explique seu raciocínio.", "allOptions": "Todas as opções", "allSubquestions": "Todas as subquestões", "chooseOptionImpactedMost": "Escolha uma opção que este driver impacta mais:", "chooseSubquestionImpactedMost": "Escolha uma subquestão que este driver impacta mais:", + "addDriverModalDescription": "Drivers são fatos impactantes, princípios ou hipotéticos. Por exemplo: “O benchmark MMLU começou a saturar no final de 2024”, “A China poderia invadir Taiwan”, “Dinâmicas de vencedor-leva-tudo”.", + "addDriverModalCommentDescription": "Drivers devem ser apoiados por fontes e argumentos. Escreva um comentário abaixo e explique seu raciocínio.", + "addDriverCommentDisclaimer": "Certifique-se de que seus drivers são respaldados por fontes e raciocínios no seu comentário.", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index cdd2c700da..826461a55a 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1544,11 +1544,12 @@ "medium": "中等", "topKeyFactors": "關鍵因素排名", "addDriver": "新增驅動因素", - "addDriverModalDescription": "在下方輸入您的驅動因素 - 盡量保持簡短明瞭。", - "addDriverModalCommentDescription": "驅動因素應有評論支撐 - 在下方輸入評論並解釋您的理由。", "allOptions": "所有選項", "allSubquestions": "所有子問題", "chooseOptionImpactedMost": "選擇此驅動因素影響最大的選項:", "chooseSubquestionImpactedMost": "選擇此驅動因素影響最大的子問題:", + "addDriverModalDescription": "驅動因素是具有影響力的事實、原則或假設。 例如:“MMLU 基準在 2024 年底開始飽和”,“中國可能入侵台灣”,“贏者通吃的動態”。", + "addDriverModalCommentDescription": "驅動因素應由來源和論據支持。 在下面寫評論並解釋你的推理。", + "addDriverCommentDisclaimer": "確保你的驅動因素在評論中有來源和推理的支持。", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 172daf6698..493f94fe04 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1549,11 +1549,12 @@ "medium": "中", "topKeyFactors": "主要关键因素", "addDriver": "添加驱动因素", - "addDriverModalDescription": "在下面输入您的驱动因素 - 尽量保持简短明了。", - "addDriverModalCommentDescription": "驱动因素应有评论支持 - 在下面输入评论并解释您的理由。", "allOptions": "所有选项", "allSubquestions": "所有子问题", "chooseOptionImpactedMost": "选择一个受此驱动因素影响最大的选项:", "chooseSubquestionImpactedMost": "选择一个受此驱动因素影响最大的子问题:", + "addDriverModalDescription": "驱动因素是重要的事实、原则或假设。例如:“MMLU 基准测试在 2024 年末开始饱和”,“中国可能入侵台湾”,“赢家通吃的动态”。", + "addDriverModalCommentDescription": "驱动因素应有来源和论据支持。在下面写下评论并解释您的推理。", + "addDriverCommentDisclaimer": "确保您的驱动因素在评论中有来源和推理的支持。", "othersCount": "其他({count})" } diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx index 2b8f438f76..7b8bce055b 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_key_factors_modal.tsx @@ -1,26 +1,30 @@ "use client"; import { faChevronRight, - faMinus, + faClose, + faPen, faPlus, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import { useTranslations } from "next-intl"; -import { FC, memo, useState } from "react"; +import { FC, memo, useEffect, useState } from "react"; import DriverCreationForm from "@/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form"; +import KeyFactorsCarousel from "@/app/(main)/questions/[id]/components/key_factors/key_factors_carousel"; import BaseModal from "@/components/base_modal"; import MarkdownEditor from "@/components/markdown_editor"; import Button from "@/components/ui/button"; import { FormError } from "@/components/ui/form_field"; import LoadingSpinner from "@/components/ui/loading_spiner"; -import { BECommentType } from "@/types/comment"; +import { useAuth } from "@/contexts/auth_context"; +import { BECommentType, KeyFactor } from "@/types/comment"; import { KeyFactorDraft } from "@/types/key_factors"; import { PostWithForecasts } from "@/types/post"; import { User } from "@/types/users"; import { useKeyFactors } from "./hooks"; +import KeyFactorItem from "./key_factor_item"; const FACTORS_PER_COMMENT = 4; @@ -54,16 +58,25 @@ export const AddKeyFactorsForm = ({ setDrafts: React.Dispatch>; limitError?: string; factorsLimit: number; - suggestedKeyFactors: { text: string; selected: boolean }[]; - setSuggestedKeyFactors: ( - factors: { text: string; selected: boolean }[] - ) => void; + suggestedKeyFactors: KeyFactorDraft[]; + setSuggestedKeyFactors: React.Dispatch< + React.SetStateAction + >; post: PostWithForecasts; }) => { const t = useTranslations(); + const { user } = useAuth(); + useEffect(() => { + if (suggestedKeyFactors.length > 0) { + setDrafts([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!user) return null; const totalKeyFactorsLimitReached = - drafts.length + suggestedKeyFactors.filter((kf) => kf.selected).length >= + drafts.length + suggestedKeyFactors.length >= Math.min(factorsLimit, FACTORS_PER_COMMENT); return ( @@ -73,37 +86,71 @@ export const AddKeyFactorsForm = ({

{t("suggestedKeyFactorsSection")}

- {suggestedKeyFactors.map((keyFactor) => ( -
- {keyFactor.text} - -
- ))} +
+ { + const fake: KeyFactor = { + ...kf, + id: -1, + author: user, + comment_id: -1, + vote: { score: 0, user_vote: null, count: 0 }, + question: kf.question_id + ? { + id: kf.question_id, + label: + post.group_of_questions?.questions.find( + (obj) => obj.id === kf.question_id + )?.label || "", + } + : undefined, + }; + + return ( +
+ +
+ + +
+
+ ); + }} + /> +
)} @@ -121,7 +168,7 @@ export const AddKeyFactorsForm = ({ setDraft={(d) => setDrafts(drafts.map((k, i) => (i === idx ? d : k))) } - showXButton={idx > 0} + showXButton={idx > 0 || !!suggestedKeyFactors.length} onXButtonClick={() => { setDrafts(drafts.filter((_, i) => i !== idx)); }} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx index e86d6d64a2..5ffb32364c 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/add_modal/driver_creation_form.tsx @@ -15,7 +15,7 @@ import { isQuestionPost, } from "@/utils/questions/helpers"; -import OptionTargetPicker, { Target } from "../option_target_picker"; +import OptionTargetPicker from "../option_target_picker"; type Props = { draft: KeyFactorDraft; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts index 43c7dedb54..94232bc3d3 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts @@ -15,11 +15,6 @@ import { ErrorResponse } from "@/types/fetch"; import { KeyFactorDraft } from "@/types/key_factors"; import { sendAnalyticsEvent } from "@/utils/analytics"; -export type SuggestedKeyFactor = { - text: string; - selected: boolean; -}; - type UseKeyFactorsProps = { user_id: number | undefined; commentId?: number; @@ -42,7 +37,7 @@ export const useKeyFactors = ({ // The drafts are managed by the caller now const [errors, setErrors] = useState(); const [suggestedKeyFactors, setSuggestedKeyFactors] = useState< - SuggestedKeyFactor[] + KeyFactorDraft[] >([]); const [isLoadingSuggestedKeyFactors, setIsLoadingSuggestedKeyFactors] = useState(false); @@ -68,12 +63,10 @@ export const useKeyFactors = ({ if (shouldLoadKeyFactors && commentId) { setIsLoadingSuggestedKeyFactors(true); ClientCommentsApi.getSuggestedKeyFactors(commentId) - .then((suggested) => { - setSuggestedKeyFactors( - suggested.map((text) => ({ text, selected: false })) - ); - onKeyFactorsLoadded?.(suggested.length !== 0); - if (suggested.length > 0) { + .then((drafts: KeyFactorWritePayload[]) => { + setSuggestedKeyFactors(drafts); + onKeyFactorsLoadded?.(drafts.length !== 0); + if (drafts.length > 0) { setTimeout(() => { const el = document.getElementById("suggested-key-factors"); if (el) { @@ -107,7 +100,7 @@ export const useKeyFactors = ({ const onSubmit = async ( submittedDrafts: KeyFactorDraft[], - suggestedKeyFactors: SuggestedKeyFactor[], + suggestedKeyFactors: KeyFactorDraft[], markdown?: string ): Promise< | { @@ -128,9 +121,9 @@ export const useKeyFactors = ({ const filteredDrafts = submittedDrafts.filter( (d) => d.driver.text.trim() !== "" ); - const filteredSuggestedKeyFactors = suggestedKeyFactors - .filter((kf) => kf.selected) - .map((kf) => kf.text); + const filteredSuggestedKeyFactors = suggestedKeyFactors.filter( + (d) => d.driver.text.trim() !== "" + ); const writePayloads: KeyFactorWritePayload[] = [ ...filteredDrafts.map((d) => @@ -142,9 +135,15 @@ export const useKeyFactors = ({ }), }) ), - ...filteredSuggestedKeyFactors.map((text) => ({ - driver: toDriverUnion({ text, impact_direction: 1, certainty: null }), - })), + ...filteredSuggestedKeyFactors.map((d) => + applyTargetForDraft(d, { + driver: toDriverUnion({ + text: d.driver.text, + impact_direction: d.driver.impact_direction ?? null, + certainty: d.driver.certainty ?? null, + }), + }) + ), ]; let comment; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx index c0db4e709e..da50d53ce5 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_carousel.tsx @@ -8,12 +8,13 @@ import cn from "@/utils/core/cn"; type Props = { items: T[]; - renderItem: (item: T) => React.ReactNode; + renderItem: (item: T, index: number) => React.ReactNode; listClassName?: string; + gapClassName?: string; }; function KeyFactorsCarousel(props: Props) { - const { items, renderItem, listClassName } = props; + const { items, renderItem, listClassName, gapClassName } = props; const isDesktop = useBreakpoint("sm"); @@ -40,11 +41,11 @@ function KeyFactorsCarousel(props: Props) { : true } itemClassName="" - gapClassName="gap-2.5" + gapClassName={cn("gap-2.5", gapClassName)} listClassName={cn("px-0", listClassName)} gradientFromClass="from-gray-0 dark:from-gray-0-dark w-[55px]" arrowClassName="right-1.5 w-10 h-10 md:w-[44px] md:h-[44px] text-blue-700 dark:text-blue-700-dark bg-gray-0 dark:bg-gray-0-dark mt-3 md:text-gray-200 md:dark:text-gray-200-dark rounded-full md:bg-blue-900 md:dark:bg-blue-900-dark" - renderItem={(item) => renderItem(item)} + renderItem={(item, i) => renderItem(item, i)} /> ); } diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 792e929223..eeecb1fc73 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -968,7 +968,7 @@ const Comment: FC = ({ submitDisabled={ isPending || (!drafts.some((k) => k.driver.text.trim() !== "") && - !suggestedKeyFactors.some((k) => k.selected)) || + suggestedKeyFactors.length === 0) || drafts.some( (d) => d.driver.text.trim() !== "" && @@ -986,6 +986,7 @@ const Comment: FC = ({ setSuggestedKeyFactors={setSuggestedKeyFactors} post={postData} /> +

{t("addDriverCommentDisclaimer")}

)} diff --git a/front_end/src/components/comment_feed/index.tsx b/front_end/src/components/comment_feed/index.tsx index 17ae339cba..bd5d92348b 100644 --- a/front_end/src/components/comment_feed/index.tsx +++ b/front_end/src/components/comment_feed/index.tsx @@ -335,10 +335,6 @@ const CommentFeed: FC = ({ ); const onNewComment = (newComment: CommentType) => { - const isSimpleQuestion = - postData?.question?.type && - postData?.question?.type !== QuestionType.MultipleChoice; - setComments([newComment, ...comments]); fetchTotalCount({ @@ -351,12 +347,7 @@ const CommentFeed: FC = ({ PostStatus.PENDING_RESOLUTION, ].includes(postData?.status ?? PostStatus.CLOSED); - if ( - postId && - isSimpleQuestion && - user?.should_suggest_keyfactors && - isPostOpen - ) { + if (postId && user?.should_suggest_keyfactors && isPostOpen) { setUserKeyFactorsComment(newComment); } }; diff --git a/front_end/src/services/api/comments/comments.shared.ts b/front_end/src/services/api/comments/comments.shared.ts index 57d389bcbe..2519a8e686 100644 --- a/front_end/src/services/api/comments/comments.shared.ts +++ b/front_end/src/services/api/comments/comments.shared.ts @@ -90,8 +90,10 @@ class CommentsApi extends ApiService { ); } - async getSuggestedKeyFactors(commentId: number): Promise { - return await this.get( + async getSuggestedKeyFactors( + commentId: number + ): Promise { + return await this.get( `/comments/${commentId}/suggested-key-factors/` ); } diff --git a/front_end/src/types/comment.ts b/front_end/src/types/comment.ts index 7e86090245..5136279c08 100644 --- a/front_end/src/types/comment.ts +++ b/front_end/src/types/comment.ts @@ -100,9 +100,10 @@ export enum ImpactDirectionCategory { IncreaseUncertainty, } -export type ImpactMetadata = - | { impact_direction: 1 | -1; certainty: null } - | { impact_direction: null; certainty: -1 }; +export type ImpactMetadata = { + impact_direction: 1 | -1 | null; + certainty: -1 | null; +}; export type Driver = ImpactMetadata & { text: string; diff --git a/tests/unit/test_comments/test_services.py b/tests/unit/test_comments/test_services.py index f8cc1f8005..1459123185 100644 --- a/tests/unit/test_comments/test_services.py +++ b/tests/unit/test_comments/test_services.py @@ -4,7 +4,7 @@ from comments.models import KeyFactorVote, KeyFactorDriver from comments.services.common import create_comment, soft_delete_comment -from comments.services.key_factors import ( +from comments.services.key_factors.common import ( key_factor_vote, create_key_factors, calculate_freshness_driver, diff --git a/utils/openai.py b/utils/openai.py index 0500eb5e79..30d4ff64b2 100644 --- a/utils/openai.py +++ b/utils/openai.py @@ -193,62 +193,12 @@ def run_spam_analysis(text: str, content_type: str) -> SpamAnalysisResult: return user -def generate_keyfactors( - question_data: str, - comment: str, - existing_keyfactors: list[str], -) -> list[str]: - MAX_LENGTH = 50 - - system_prompt = textwrap.dedent( - """ - You are a helpful assistant that creates tools for forecasters to better forecast on Metaculus, where users can predict on all sorts of questions about real world events. - """ - ) - - user_prompt = textwrap.dedent( - f""" - You are a helpful assistant that generates a list of maximum 3 key factors for a comment that a user makes on a Metaculus question. - The comment is intended to describe what might influence the predictions on the question so the key factors should only be relate to that. - The key factors should be the most important things that the user is trying to say in the comment and how it might influence the predictions on the question. - The key factors should be single sentences, not longer than {MAX_LENGTH} characters and they should only contain the key factor, no other text (e.g.: do not reference the user). - - The user comment is: \n\n{comment}\n\n - The Metaculus question is: \n\n{question_data}\n\n - The existing key factors are: \n\n{existing_keyfactors}\n\n - - Do not include any key factors that are already in the existing key factors list. Read that carefully and make sure you don't have any duplicates. - - If we are not sure the comment has meaningful key factors information, return the literal string "None". Better be conservative than creating meaningless key factors. - - Each key factor should be a single sentence, not longer than {MAX_LENGTH} characters, and they should follow this format: - - separate each key factor with a new line - - do not include any other text - - do not include any formatting like quotes, numbering or other punctuation - - do not include any other formatting like bold or italic - - do not include anything else than the key factors - """ - ) - - client = get_openai_client(settings.OPENAI_API_KEY_FACTORS) - - response = client.chat.completions.create( - model="gpt-4o", - messages=[ - { - "role": "system", - "content": system_prompt, - }, - { - "role": "user", - "content": user_prompt, - }, - ], - ) - keyfactors = response.choices[0].message.content - - if keyfactors is None or keyfactors.lower() == "none": - return [] - - keyfactors = keyfactors.split("\n") - return [keyfactor.strip().strip('"').strip("'") for keyfactor in keyfactors] +def pydantic_to_openai_json_schema(model: BaseModel) -> dict: + """Convert a Pydantic model into an OpenAI-compatible JSON schema.""" + return { + "type": "json_schema", + "json_schema": { + "name": "key_factors_response", + "schema": model.model_json_schema(), + }, + } From dc18c83a99f1914b4015c676e5c0b47724649351 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 24 Oct 2025 14:36:40 +0200 Subject: [PATCH 23/23] Key Factors that are migrated from the old system display empty strength bar when no votes --- .../migrations/0020_keyfactors_votes_migration_and_cleanup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py index 6f7f0f1c84..74038f0919 100644 --- a/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py +++ b/comments/migrations/0020_keyfactors_votes_migration_and_cleanup.py @@ -86,7 +86,9 @@ def votes_migration(apps, schema_editor): update_votes.append(vote) # Calculate strength - kf.votes_score = calculate_votes_strength([v.score for v in votes]) + 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"])