From 746de3a2e1e17495f93270c459343149d4079d3d Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Wed, 11 Sep 2024 14:18:06 +0100 Subject: [PATCH 1/2] feat #admin: implement reasoning display --- .../admin/actions/CountVotesAction.ts | 18 +- src/logic/VoteCountingLogic.ts | 212 ++++++++++++++++++ 2 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/channels/admin/actions/CountVotesAction.ts b/src/channels/admin/actions/CountVotesAction.ts index e9e5362..8bc89ca 100644 --- a/src/channels/admin/actions/CountVotesAction.ts +++ b/src/channels/admin/actions/CountVotesAction.ts @@ -75,21 +75,19 @@ export class CountVotesAction extends Action { const progressEmbed = new EmbedBuilder() .setColor('#0099ff') .setTitle('Counting Votes') - .setDescription('Please wait while our quantum computers count all possible vote outcomes...'); + .setDescription('Please wait while we count the votes and gather reasoning...'); await interaction.interaction.editReply({ embeds: [progressEmbed], components: [] }); let updateCounter = 0; const updateInterval = setInterval(async () => { updateCounter++; - progressEmbed.setDescription( - `Please wait while our quantum computers count all possible vote outcomes...\nTime elapsed: ${updateCounter * 5} seconds`, - ); + progressEmbed.setDescription(`Please wait while we count the votes and gather reasoning...\nTime elapsed: ${updateCounter * 5} seconds`); await interaction.interaction.editReply({ embeds: [progressEmbed] }); }, 5000); try { - const voteResults = await VoteCountingLogic.countVotes(parseInt(fundingRoundId), phase, interaction); + const voteResults = await VoteCountingLogic.countVotesWithReasoning(parseInt(fundingRoundId), phase, interaction); clearInterval(updateInterval); @@ -116,6 +114,16 @@ export class CountVotesAction extends Action { }); await interaction.interaction.editReply({ embeds: [resultEmbed], components: [] }); + + // Send vote reasoning as follow-up messages with embeds + const reasoningEmbeds = VoteCountingLogic.formatVoteReasoningMessage(voteResults); + if (reasoningEmbeds.length > 0) { + for (const embed of reasoningEmbeds) { + await interaction.interaction.followUp({ embeds: [embed], ephemeral: true }); + } + } else { + await interaction.interaction.followUp({ content: 'No vote reasoning available for any projects.', ephemeral: true }); + } } catch (error) { clearInterval(updateInterval); if (error instanceof EndUserError) { diff --git a/src/logic/VoteCountingLogic.ts b/src/logic/VoteCountingLogic.ts index 805ebca..291ed5f 100644 --- a/src/logic/VoteCountingLogic.ts +++ b/src/logic/VoteCountingLogic.ts @@ -4,6 +4,7 @@ import { CommitteeDeliberationVoteChoice } from '../types'; import { TrackedInteraction } from '../core/BaseClasses'; import logger from '../logging'; import { Op } from 'sequelize'; +import { EmbedBuilder } from 'discord.js'; interface VoteResult { projectId: number; @@ -17,6 +18,19 @@ interface VoteResult { approvedModifiedVoters?: string[]; } +interface VoteResultWithReasoning extends VoteResult { + deliberationVotes: { + voterUsername: string; + vote: CommitteeDeliberationVoteChoice; + reason: string | null; + }[]; + considerationVotes: { + voterUsername: string; + isPass: boolean; + reason: string | null; + }[]; +} + export class VoteCountingLogic { public static async countVotes(fundingRoundId: number, phase: string, trackedInteraction: TrackedInteraction): Promise { const fundingRound = await FundingRound.findByPk(fundingRoundId, { include: [Proposal] }); @@ -175,4 +189,202 @@ export class VoteCountingLogic { }), ); } + + public static async countVotesWithReasoning( + fundingRoundId: number, + phase: string, + trackedInteraction: TrackedInteraction, + ): Promise { + const fundingRound = await FundingRound.findByPk(fundingRoundId, { include: [Proposal] }); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); + } + + const { FundingRoundLogic } = await import('../channels/admin/screens/FundingRoundLogic'); + const proposals = await FundingRoundLogic.getProposalsForFundingRound(fundingRoundId); + let voteResults: VoteResultWithReasoning[] = []; + + switch (phase) { + case 'consideration': + voteResults = await this.countConsiderationVotesWithReasoning(proposals, trackedInteraction); + break; + case 'deliberation': + voteResults = await this.countDeliberationVotesWithReasoning(proposals, trackedInteraction); + break; + case 'voting': + throw new EndUserError('Voting phase vote counting is not yet implemented'); + default: + throw new EndUserError(`Invalid phase selected: ${phase}`); + } + + return voteResults.sort((a, b) => { + if (a.yesVotes !== b.yesVotes) { + return b.yesVotes - a.yesVotes; + } + if (a.noVotes !== b.noVotes) { + return b.noVotes - a.noVotes; + } + return (b.approvedModifiedVotes || 0) - (a.approvedModifiedVotes || 0); + }); + } + + private static async countConsiderationVotesWithReasoning( + proposals: Proposal[], + trackedInteraction: TrackedInteraction, + ): Promise { + return Promise.all( + proposals.map(async (proposal) => { + const allVotes = await SMEConsiderationVoteLog.findAll({ + where: { proposalId: proposal.id }, + order: [['createdAt', 'DESC']], + }); + + const latestVotes = new Map(); + const yesVoters: string[] = []; + const noVoters: string[] = []; + const considerationVotes: VoteResultWithReasoning['considerationVotes'] = []; + + for (const vote of allVotes) { + if (!latestVotes.has(vote.duid)) { + latestVotes.set(vote.duid, vote.isPass); + const voterUsername = await this.getUsername(trackedInteraction, vote.duid); + considerationVotes.push({ + voterUsername, + isPass: vote.isPass, + reason: vote.reason, + }); + if (vote.isPass) { + yesVoters.push(vote.duid); + } else { + noVoters.push(vote.duid); + } + } + } + + const yesVotes = yesVoters.length; + const noVotes = noVoters.length; + + const yesVoterUsernames = await this.getVoterUsernames(trackedInteraction, yesVoters); + const noVoterUsernames = await this.getVoterUsernames(trackedInteraction, noVoters); + + const proposerUsername = await this.getUsername(trackedInteraction, proposal.proposerDuid); + + return { + projectId: proposal.id, + projectName: proposal.name, + proposerUsername, + yesVotes, + noVotes, + yesVoters: yesVoterUsernames, + noVoters: noVoterUsernames, + deliberationVotes: [], + considerationVotes, + }; + }), + ); + } + + private static async countDeliberationVotesWithReasoning( + proposals: Proposal[], + trackedInteraction: TrackedInteraction, + ): Promise { + return Promise.all( + proposals.map(async (proposal) => { + const allVotes = await CommitteeDeliberationVoteLog.findAll({ + where: { proposalId: proposal.id }, + order: [['createdAt', 'DESC']], + }); + + const latestVotes = new Map(); + const yesVoters: string[] = []; + const noVoters: string[] = []; + const approvedModifiedVoters: string[] = []; + const deliberationVotes: VoteResultWithReasoning['deliberationVotes'] = []; + + for (const vote of allVotes) { + if (!latestVotes.has(vote.duid)) { + latestVotes.set(vote.duid, vote.vote); + const voterUsername = await this.getUsername(trackedInteraction, vote.duid); + deliberationVotes.push({ + voterUsername, + vote: vote.vote, + reason: vote.reason, + }); + switch (vote.vote) { + case CommitteeDeliberationVoteChoice.APPROVED: + yesVoters.push(vote.duid); + break; + case CommitteeDeliberationVoteChoice.REJECTED: + noVoters.push(vote.duid); + break; + case CommitteeDeliberationVoteChoice.APPROVED_MODIFIED: + approvedModifiedVoters.push(vote.duid); + break; + } + } + } + + const yesVotes = yesVoters.length; + const noVotes = noVoters.length; + const approvedModifiedVotes = approvedModifiedVoters.length; + + const yesVoterUsernames = await this.getVoterUsernames(trackedInteraction, yesVoters); + const noVoterUsernames = await this.getVoterUsernames(trackedInteraction, noVoters); + const approvedModifiedVoterUsernames = await this.getVoterUsernames(trackedInteraction, approvedModifiedVoters); + + const proposerUsername = await this.getUsername(trackedInteraction, proposal.proposerDuid); + + return { + projectId: proposal.id, + projectName: proposal.name, + proposerUsername, + yesVotes, + noVotes, + approvedModifiedVotes, + yesVoters: yesVoterUsernames, + noVoters: noVoterUsernames, + approvedModifiedVoters: approvedModifiedVoterUsernames, + deliberationVotes, + considerationVotes: [], + }; + }), + ); + } + + public static formatVoteReasoningMessage(voteResults: VoteResultWithReasoning[]): EmbedBuilder[] { + const embeds: EmbedBuilder[] = []; + + for (const result of voteResults) { + if (result.deliberationVotes.length === 0 && result.considerationVotes.length === 0) { + continue; // Skip projects with no votes + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Vote Reasoning - ${result.projectName} (ID: ${result.projectId})`) + .setDescription(`Proposer: ${result.proposerUsername}`); + + if (result.deliberationVotes.length > 0) { + let deliberationField = ''; + for (const vote of result.deliberationVotes) { + deliberationField += `**${vote.voterUsername}**: ${vote.vote}\n`; + deliberationField += `Reasoning: ${vote.reason || 'No reason provided'}\n\n`; + } + embed.addFields({ name: 'Deliberation Phase Votes', value: deliberationField.trim() }); + } + + if (result.considerationVotes.length > 0) { + let considerationField = ''; + for (const vote of result.considerationVotes) { + considerationField += `**${vote.voterUsername}**: ${vote.isPass ? 'Yes' : 'No'}\n`; + considerationField += `Reasoning: ${vote.reason || 'No reason provided'}\n\n`; + } + embed.addFields({ name: 'Consideration Phase Votes', value: considerationField.trim() }); + } + + embeds.push(embed); + } + + return embeds; + } } From 831fde1721fe0390d7018972432c249476fc1d29 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Wed, 11 Sep 2024 14:18:29 +0100 Subject: [PATCH 2/2] chore: bump version to 0.0.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 089696d..a379465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mina-govbot", - "version": "0.0.13", + "version": "0.0.14", "description": "Discord bot for collective decision making for Mina Protocol", "main": "index.js", "directories": {