mcp-rubber-duck 1.2.5 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +1 -0
- package/CHANGELOG.md +12 -0
- package/README.md +116 -2
- package/dist/config/types.d.ts +78 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +150 -0
- package/dist/server.js.map +1 -1
- package/dist/services/consensus.d.ts +28 -0
- package/dist/services/consensus.d.ts.map +1 -0
- package/dist/services/consensus.js +257 -0
- package/dist/services/consensus.js.map +1 -0
- package/dist/tools/duck-debate.d.ts +16 -0
- package/dist/tools/duck-debate.d.ts.map +1 -0
- package/dist/tools/duck-debate.js +272 -0
- package/dist/tools/duck-debate.js.map +1 -0
- package/dist/tools/duck-iterate.d.ts +14 -0
- package/dist/tools/duck-iterate.d.ts.map +1 -0
- package/dist/tools/duck-iterate.js +195 -0
- package/dist/tools/duck-iterate.js.map +1 -0
- package/dist/tools/duck-judge.d.ts +15 -0
- package/dist/tools/duck-judge.d.ts.map +1 -0
- package/dist/tools/duck-judge.js +208 -0
- package/dist/tools/duck-judge.js.map +1 -0
- package/dist/tools/duck-vote.d.ts +14 -0
- package/dist/tools/duck-vote.d.ts.map +1 -0
- package/dist/tools/duck-vote.js +46 -0
- package/dist/tools/duck-vote.js.map +1 -0
- package/package.json +1 -1
- package/src/config/types.ts +92 -0
- package/src/server.ts +154 -0
- package/src/services/consensus.ts +324 -0
- package/src/tools/duck-debate.ts +383 -0
- package/src/tools/duck-iterate.ts +253 -0
- package/src/tools/duck-judge.ts +301 -0
- package/src/tools/duck-vote.ts +87 -0
- package/tests/consensus.test.ts +282 -0
- package/tests/duck-debate.test.ts +286 -0
- package/tests/duck-iterate.test.ts +249 -0
- package/tests/duck-judge.test.ts +296 -0
- package/tests/duck-vote.test.ts +250 -0
package/src/server.ts
CHANGED
|
@@ -27,6 +27,10 @@ import { listDucksTool } from './tools/list-ducks.js';
|
|
|
27
27
|
import { listModelsTool } from './tools/list-models.js';
|
|
28
28
|
import { compareDucksTool } from './tools/compare-ducks.js';
|
|
29
29
|
import { duckCouncilTool } from './tools/duck-council.js';
|
|
30
|
+
import { duckVoteTool } from './tools/duck-vote.js';
|
|
31
|
+
import { duckJudgeTool } from './tools/duck-judge.js';
|
|
32
|
+
import { duckIterateTool } from './tools/duck-iterate.js';
|
|
33
|
+
import { duckDebateTool } from './tools/duck-debate.js';
|
|
30
34
|
|
|
31
35
|
// Import MCP tools
|
|
32
36
|
import { getPendingApprovalsTool } from './tools/get-pending-approvals.js';
|
|
@@ -162,6 +166,18 @@ export class RubberDuckServer {
|
|
|
162
166
|
}
|
|
163
167
|
return await duckCouncilTool(this.providerManager, args || {});
|
|
164
168
|
|
|
169
|
+
case 'duck_vote':
|
|
170
|
+
return await duckVoteTool(this.providerManager, args || {});
|
|
171
|
+
|
|
172
|
+
case 'duck_judge':
|
|
173
|
+
return await duckJudgeTool(this.providerManager, args || {});
|
|
174
|
+
|
|
175
|
+
case 'duck_iterate':
|
|
176
|
+
return await duckIterateTool(this.providerManager, args || {});
|
|
177
|
+
|
|
178
|
+
case 'duck_debate':
|
|
179
|
+
return await duckDebateTool(this.providerManager, args || {});
|
|
180
|
+
|
|
165
181
|
// MCP-specific tools
|
|
166
182
|
case 'get_pending_approvals':
|
|
167
183
|
if (!this.approvalService) {
|
|
@@ -487,6 +503,144 @@ export class RubberDuckServer {
|
|
|
487
503
|
required: ['prompt'],
|
|
488
504
|
},
|
|
489
505
|
},
|
|
506
|
+
{
|
|
507
|
+
name: 'duck_vote',
|
|
508
|
+
description: 'Have multiple ducks vote on options with reasoning. Returns vote tally, confidence scores, and consensus level.',
|
|
509
|
+
inputSchema: {
|
|
510
|
+
type: 'object',
|
|
511
|
+
properties: {
|
|
512
|
+
question: {
|
|
513
|
+
type: 'string',
|
|
514
|
+
description: 'The question to vote on (e.g., "Best approach for error handling?")',
|
|
515
|
+
},
|
|
516
|
+
options: {
|
|
517
|
+
type: 'array',
|
|
518
|
+
items: { type: 'string' },
|
|
519
|
+
minItems: 2,
|
|
520
|
+
maxItems: 10,
|
|
521
|
+
description: 'The options to vote on (2-10 options)',
|
|
522
|
+
},
|
|
523
|
+
voters: {
|
|
524
|
+
type: 'array',
|
|
525
|
+
items: { type: 'string' },
|
|
526
|
+
description: 'List of provider names to vote (optional, uses all if not specified)',
|
|
527
|
+
},
|
|
528
|
+
require_reasoning: {
|
|
529
|
+
type: 'boolean',
|
|
530
|
+
default: true,
|
|
531
|
+
description: 'Require ducks to explain their vote (default: true)',
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
required: ['question', 'options'],
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
name: 'duck_judge',
|
|
539
|
+
description: 'Have one duck evaluate and rank other ducks\' responses. Use after duck_council to get a comparative evaluation.',
|
|
540
|
+
inputSchema: {
|
|
541
|
+
type: 'object',
|
|
542
|
+
properties: {
|
|
543
|
+
responses: {
|
|
544
|
+
type: 'array',
|
|
545
|
+
items: {
|
|
546
|
+
type: 'object',
|
|
547
|
+
properties: {
|
|
548
|
+
provider: { type: 'string' },
|
|
549
|
+
nickname: { type: 'string' },
|
|
550
|
+
model: { type: 'string' },
|
|
551
|
+
content: { type: 'string' },
|
|
552
|
+
},
|
|
553
|
+
required: ['provider', 'nickname', 'content'],
|
|
554
|
+
},
|
|
555
|
+
minItems: 2,
|
|
556
|
+
description: 'Array of duck responses to evaluate (from duck_council output)',
|
|
557
|
+
},
|
|
558
|
+
judge: {
|
|
559
|
+
type: 'string',
|
|
560
|
+
description: 'Provider name of the judge duck (optional, uses first available)',
|
|
561
|
+
},
|
|
562
|
+
criteria: {
|
|
563
|
+
type: 'array',
|
|
564
|
+
items: { type: 'string' },
|
|
565
|
+
description: 'Evaluation criteria (default: ["accuracy", "completeness", "clarity"])',
|
|
566
|
+
},
|
|
567
|
+
persona: {
|
|
568
|
+
type: 'string',
|
|
569
|
+
description: 'Judge persona (e.g., "senior engineer", "security expert")',
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
required: ['responses'],
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: 'duck_iterate',
|
|
577
|
+
description: 'Iteratively refine a response between two ducks. One generates, the other critiques/improves, alternating for multiple rounds.',
|
|
578
|
+
inputSchema: {
|
|
579
|
+
type: 'object',
|
|
580
|
+
properties: {
|
|
581
|
+
prompt: {
|
|
582
|
+
type: 'string',
|
|
583
|
+
description: 'The initial prompt/task to iterate on',
|
|
584
|
+
},
|
|
585
|
+
iterations: {
|
|
586
|
+
type: 'number',
|
|
587
|
+
minimum: 1,
|
|
588
|
+
maximum: 10,
|
|
589
|
+
default: 3,
|
|
590
|
+
description: 'Number of iteration rounds (default: 3, max: 10)',
|
|
591
|
+
},
|
|
592
|
+
providers: {
|
|
593
|
+
type: 'array',
|
|
594
|
+
items: { type: 'string' },
|
|
595
|
+
minItems: 2,
|
|
596
|
+
maxItems: 2,
|
|
597
|
+
description: 'Exactly 2 provider names for the ping-pong iteration',
|
|
598
|
+
},
|
|
599
|
+
mode: {
|
|
600
|
+
type: 'string',
|
|
601
|
+
enum: ['refine', 'critique-improve'],
|
|
602
|
+
description: 'refine: each duck improves the previous response. critique-improve: alternates between critiquing and improving.',
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
required: ['prompt', 'providers', 'mode'],
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: 'duck_debate',
|
|
610
|
+
description: 'Structured multi-round debate between ducks. Supports oxford (pro/con), socratic (questioning), and adversarial (attack/defend) formats.',
|
|
611
|
+
inputSchema: {
|
|
612
|
+
type: 'object',
|
|
613
|
+
properties: {
|
|
614
|
+
prompt: {
|
|
615
|
+
type: 'string',
|
|
616
|
+
description: 'The debate topic or proposition',
|
|
617
|
+
},
|
|
618
|
+
rounds: {
|
|
619
|
+
type: 'number',
|
|
620
|
+
minimum: 1,
|
|
621
|
+
maximum: 10,
|
|
622
|
+
default: 3,
|
|
623
|
+
description: 'Number of debate rounds (default: 3)',
|
|
624
|
+
},
|
|
625
|
+
providers: {
|
|
626
|
+
type: 'array',
|
|
627
|
+
items: { type: 'string' },
|
|
628
|
+
minItems: 2,
|
|
629
|
+
description: 'Provider names to participate (min 2, uses all if not specified)',
|
|
630
|
+
},
|
|
631
|
+
format: {
|
|
632
|
+
type: 'string',
|
|
633
|
+
enum: ['oxford', 'socratic', 'adversarial'],
|
|
634
|
+
description: 'Debate format: oxford (pro/con), socratic (questioning), adversarial (attack/defend)',
|
|
635
|
+
},
|
|
636
|
+
synthesizer: {
|
|
637
|
+
type: 'string',
|
|
638
|
+
description: 'Provider to synthesize the debate (optional, uses first provider)',
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
required: ['prompt', 'format'],
|
|
642
|
+
},
|
|
643
|
+
},
|
|
490
644
|
];
|
|
491
645
|
|
|
492
646
|
// Add MCP-specific tools if enabled
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { VoteResult, AggregatedVote } from '../config/types.js';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
interface ParsedVote {
|
|
5
|
+
choice?: string;
|
|
6
|
+
confidence?: number | string;
|
|
7
|
+
reasoning?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ConsensusService {
|
|
11
|
+
/**
|
|
12
|
+
* Build a voting prompt that asks the LLM to vote on options
|
|
13
|
+
*/
|
|
14
|
+
buildVotePrompt(
|
|
15
|
+
question: string,
|
|
16
|
+
options: string[],
|
|
17
|
+
requireReasoning: boolean = true
|
|
18
|
+
): string {
|
|
19
|
+
const optionsList = options.map((opt, i) => `${i + 1}. ${opt}`).join('\n');
|
|
20
|
+
|
|
21
|
+
const format = requireReasoning
|
|
22
|
+
? `{
|
|
23
|
+
"choice": "<exact option text>",
|
|
24
|
+
"confidence": <0-100>,
|
|
25
|
+
"reasoning": "<brief explanation>"
|
|
26
|
+
}`
|
|
27
|
+
: `{
|
|
28
|
+
"choice": "<exact option text>",
|
|
29
|
+
"confidence": <0-100>
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
return `You are voting on the following question. You MUST choose exactly ONE option from the list below.
|
|
33
|
+
|
|
34
|
+
QUESTION: ${question}
|
|
35
|
+
|
|
36
|
+
OPTIONS:
|
|
37
|
+
${optionsList}
|
|
38
|
+
|
|
39
|
+
INSTRUCTIONS:
|
|
40
|
+
1. Analyze each option carefully
|
|
41
|
+
2. Choose the BEST option based on your knowledge and reasoning
|
|
42
|
+
3. Respond with ONLY a JSON object in this exact format:
|
|
43
|
+
|
|
44
|
+
${format}
|
|
45
|
+
|
|
46
|
+
IMPORTANT:
|
|
47
|
+
- "choice" must be the EXACT text of one of the options above
|
|
48
|
+
- "confidence" must be a number from 0 to 100
|
|
49
|
+
- Do NOT include any text before or after the JSON
|
|
50
|
+
- Do NOT use markdown code blocks`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse a vote from an LLM response
|
|
55
|
+
*/
|
|
56
|
+
parseVote(
|
|
57
|
+
response: string,
|
|
58
|
+
voter: string,
|
|
59
|
+
nickname: string,
|
|
60
|
+
options: string[]
|
|
61
|
+
): VoteResult {
|
|
62
|
+
const result: VoteResult = {
|
|
63
|
+
voter,
|
|
64
|
+
nickname,
|
|
65
|
+
choice: '',
|
|
66
|
+
confidence: 0,
|
|
67
|
+
reasoning: '',
|
|
68
|
+
rawResponse: response,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Try to extract JSON from the response (greedy to handle nested objects)
|
|
73
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
74
|
+
if (!jsonMatch) {
|
|
75
|
+
logger.warn(`No JSON found in vote response from ${voter}`);
|
|
76
|
+
return this.fallbackParse(response, voter, nickname, options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = JSON.parse(jsonMatch[0]) as ParsedVote;
|
|
80
|
+
|
|
81
|
+
// Validate choice
|
|
82
|
+
const choice = parsed.choice?.toString().trim();
|
|
83
|
+
if (choice) {
|
|
84
|
+
// Try exact match first
|
|
85
|
+
const exactMatch = options.find(
|
|
86
|
+
opt => opt.toLowerCase() === choice.toLowerCase()
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (exactMatch) {
|
|
90
|
+
result.choice = exactMatch;
|
|
91
|
+
} else {
|
|
92
|
+
// Try partial match
|
|
93
|
+
const partialMatch = options.find(
|
|
94
|
+
opt => opt.toLowerCase().includes(choice.toLowerCase()) ||
|
|
95
|
+
choice.toLowerCase().includes(opt.toLowerCase())
|
|
96
|
+
);
|
|
97
|
+
if (partialMatch) {
|
|
98
|
+
result.choice = partialMatch;
|
|
99
|
+
logger.debug(`Fuzzy matched "${choice}" to "${partialMatch}" for ${voter}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Parse confidence
|
|
105
|
+
if (typeof parsed.confidence === 'number') {
|
|
106
|
+
result.confidence = Math.max(0, Math.min(100, parsed.confidence));
|
|
107
|
+
} else if (typeof parsed.confidence === 'string') {
|
|
108
|
+
const conf = parseFloat(parsed.confidence);
|
|
109
|
+
if (!isNaN(conf)) {
|
|
110
|
+
result.confidence = Math.max(0, Math.min(100, conf));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Parse reasoning
|
|
115
|
+
if (parsed.reasoning) {
|
|
116
|
+
result.reasoning = parsed.reasoning.toString().trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.warn(`Failed to parse JSON vote from ${voter}:`, error);
|
|
121
|
+
return this.fallbackParse(response, voter, nickname, options);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fallback parsing when JSON fails - try to extract choice from text
|
|
129
|
+
*/
|
|
130
|
+
private fallbackParse(
|
|
131
|
+
response: string,
|
|
132
|
+
voter: string,
|
|
133
|
+
nickname: string,
|
|
134
|
+
options: string[]
|
|
135
|
+
): VoteResult {
|
|
136
|
+
const result: VoteResult = {
|
|
137
|
+
voter,
|
|
138
|
+
nickname,
|
|
139
|
+
choice: '',
|
|
140
|
+
confidence: 50, // Default confidence for fallback
|
|
141
|
+
reasoning: 'Vote extracted via fallback parsing',
|
|
142
|
+
rawResponse: response,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Try to find any option mentioned in the response
|
|
146
|
+
const responseLower = response.toLowerCase();
|
|
147
|
+
for (const option of options) {
|
|
148
|
+
if (responseLower.includes(option.toLowerCase())) {
|
|
149
|
+
result.choice = option;
|
|
150
|
+
logger.debug(`Fallback parsed choice "${option}" from ${voter}`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Aggregate votes into a final result
|
|
160
|
+
*/
|
|
161
|
+
aggregateVotes(
|
|
162
|
+
question: string,
|
|
163
|
+
options: string[],
|
|
164
|
+
votes: VoteResult[]
|
|
165
|
+
): AggregatedVote {
|
|
166
|
+
// Initialize tally and confidence tracking
|
|
167
|
+
const tally: Record<string, number> = {};
|
|
168
|
+
const confidenceSums: Record<string, number> = {};
|
|
169
|
+
const confidenceCounts: Record<string, number> = {};
|
|
170
|
+
|
|
171
|
+
for (const option of options) {
|
|
172
|
+
tally[option] = 0;
|
|
173
|
+
confidenceSums[option] = 0;
|
|
174
|
+
confidenceCounts[option] = 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Count votes
|
|
178
|
+
let validVotes = 0;
|
|
179
|
+
for (const vote of votes) {
|
|
180
|
+
if (vote.choice && options.includes(vote.choice)) {
|
|
181
|
+
tally[vote.choice]++;
|
|
182
|
+
confidenceSums[vote.choice] += vote.confidence;
|
|
183
|
+
confidenceCounts[vote.choice]++;
|
|
184
|
+
validVotes++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Calculate average confidence per option
|
|
189
|
+
const confidenceByOption: Record<string, number> = {};
|
|
190
|
+
for (const option of options) {
|
|
191
|
+
confidenceByOption[option] = confidenceCounts[option] > 0
|
|
192
|
+
? Math.round(confidenceSums[option] / confidenceCounts[option])
|
|
193
|
+
: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Determine winner
|
|
197
|
+
const maxVotes = Math.max(...Object.values(tally));
|
|
198
|
+
const winners = options.filter(opt => tally[opt] === maxVotes && maxVotes > 0);
|
|
199
|
+
|
|
200
|
+
const isTie = winners.length > 1;
|
|
201
|
+
let winner: string | null = null;
|
|
202
|
+
|
|
203
|
+
if (winners.length === 1) {
|
|
204
|
+
winner = winners[0];
|
|
205
|
+
} else if (isTie && winners.length > 0) {
|
|
206
|
+
// Break tie by confidence
|
|
207
|
+
let highestConfidence = -1;
|
|
208
|
+
for (const w of winners) {
|
|
209
|
+
if (confidenceByOption[w] > highestConfidence) {
|
|
210
|
+
highestConfidence = confidenceByOption[w];
|
|
211
|
+
winner = w;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Determine consensus level
|
|
217
|
+
const consensusLevel = this.determineConsensusLevel(
|
|
218
|
+
validVotes,
|
|
219
|
+
votes.length,
|
|
220
|
+
maxVotes,
|
|
221
|
+
isTie
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
question,
|
|
226
|
+
options,
|
|
227
|
+
winner,
|
|
228
|
+
isTie,
|
|
229
|
+
tally,
|
|
230
|
+
confidenceByOption,
|
|
231
|
+
votes,
|
|
232
|
+
totalVoters: votes.length,
|
|
233
|
+
validVotes,
|
|
234
|
+
consensusLevel,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Determine the level of consensus reached
|
|
240
|
+
*/
|
|
241
|
+
private determineConsensusLevel(
|
|
242
|
+
validVotes: number,
|
|
243
|
+
totalVoters: number,
|
|
244
|
+
maxVotes: number,
|
|
245
|
+
isTie: boolean
|
|
246
|
+
): 'unanimous' | 'majority' | 'plurality' | 'split' | 'none' {
|
|
247
|
+
if (validVotes === 0) {
|
|
248
|
+
return 'none';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const winnerRatio = maxVotes / validVotes;
|
|
252
|
+
|
|
253
|
+
if (winnerRatio === 1 && validVotes === totalVoters) {
|
|
254
|
+
return 'unanimous';
|
|
255
|
+
} else if (winnerRatio > 0.5) {
|
|
256
|
+
return 'majority';
|
|
257
|
+
} else if (!isTie && maxVotes > 0) {
|
|
258
|
+
return 'plurality';
|
|
259
|
+
} else if (isTie) {
|
|
260
|
+
return 'split';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return 'none';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Format the aggregated vote result for display
|
|
268
|
+
*/
|
|
269
|
+
formatVoteResult(result: AggregatedVote): string {
|
|
270
|
+
let output = `🗳️ **Vote Results**\n`;
|
|
271
|
+
output += `═══════════════════════════════════════\n\n`;
|
|
272
|
+
output += `**Question:** ${result.question}\n\n`;
|
|
273
|
+
|
|
274
|
+
// Winner announcement
|
|
275
|
+
if (result.winner) {
|
|
276
|
+
const emoji = result.consensusLevel === 'unanimous' ? '🏆' :
|
|
277
|
+
result.consensusLevel === 'majority' ? '✅' : '📊';
|
|
278
|
+
output += `${emoji} **Winner:** ${result.winner}`;
|
|
279
|
+
if (result.isTie) {
|
|
280
|
+
output += ` (tie-breaker by confidence)`;
|
|
281
|
+
}
|
|
282
|
+
output += `\n`;
|
|
283
|
+
output += `📈 **Consensus:** ${result.consensusLevel}\n\n`;
|
|
284
|
+
} else {
|
|
285
|
+
output += `⚠️ **No valid votes recorded**\n\n`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Vote tally
|
|
289
|
+
output += `**Vote Tally:**\n`;
|
|
290
|
+
const sortedOptions = [...result.options].sort(
|
|
291
|
+
(a, b) => result.tally[b] - result.tally[a]
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
for (const option of sortedOptions) {
|
|
295
|
+
const votes = result.tally[option];
|
|
296
|
+
const confidence = result.confidenceByOption[option];
|
|
297
|
+
const bar = '█'.repeat(Math.min(votes * 3, 15));
|
|
298
|
+
const isWinner = option === result.winner;
|
|
299
|
+
const marker = isWinner ? ' 👑' : '';
|
|
300
|
+
output += ` ${option}: ${bar} ${votes} vote(s) (avg confidence: ${confidence}%)${marker}\n`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
output += `\n**Individual Votes:**\n`;
|
|
304
|
+
output += `─────────────────────────────────────\n`;
|
|
305
|
+
|
|
306
|
+
for (const vote of result.votes) {
|
|
307
|
+
if (vote.choice) {
|
|
308
|
+
output += `🦆 **${vote.nickname}** voted: **${vote.choice}**`;
|
|
309
|
+
output += ` (confidence: ${vote.confidence}%)\n`;
|
|
310
|
+
if (vote.reasoning) {
|
|
311
|
+
output += ` 💭 "${vote.reasoning}"\n`;
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
output += `🦆 **${vote.nickname}**: ❌ Invalid vote\n`;
|
|
315
|
+
}
|
|
316
|
+
output += `\n`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
output += `═══════════════════════════════════════\n`;
|
|
320
|
+
output += `📊 ${result.validVotes}/${result.totalVoters} valid votes\n`;
|
|
321
|
+
|
|
322
|
+
return output;
|
|
323
|
+
}
|
|
324
|
+
}
|