diff --git a/platforms/cerberus/client/src/index.ts b/platforms/cerberus/client/src/index.ts index 626b8ebf3..8ae76a2eb 100644 --- a/platforms/cerberus/client/src/index.ts +++ b/platforms/cerberus/client/src/index.ts @@ -138,47 +138,19 @@ app.listen(port, () => { console.log(`Cerberus API running on port ${port}`); }); -// Initialize Cerberus intervals and periodic check-ins for groups with charters +// Initialize Cerberus intervals for groups with charters setTimeout(async () => { try { console.log("🐕 Starting Cerberus services..."); - + // Import services after server is running - const { CharterMonitoringService } = await import("./services/CharterMonitoringService"); - const { GroupService } = await import("./services/GroupService"); const { CerberusIntervalService } = await import("./services/CerberusIntervalService"); - - const charterMonitoringService = new CharterMonitoringService(); - const groupService = new GroupService(); + const intervalService = new CerberusIntervalService(); - + // Initialize Cerberus intervals for all groups with charters await intervalService.initializeIntervals(); - - // Send periodic check-ins every 24 hours (separate from charter-based intervals) - setInterval(async () => { - try { - const groups = await groupService.getAllGroups(); - const groupsWithCharters = groups.filter(group => group.charter && group.charter.trim() !== ''); - - console.log(`🐕 Sending periodic check-ins to ${groupsWithCharters.length} groups with charters...`); - - for (const group of groupsWithCharters) { - try { - await charterMonitoringService.sendPeriodicCheckIn(group.id, group.name); - // Add a small delay between messages to avoid overwhelming the system - await new Promise(resolve => setTimeout(resolve, 1000)); - } catch (error) { - console.error(`Error sending check-in to group ${group.name}:`, error); - } - } - - console.log("✅ Periodic check-ins completed"); - } catch (error) { - console.error("Error during periodic check-ins:", error); - } - }, 24 * 60 * 60 * 1000); // 24 hours - + console.log("✅ Cerberus services initialized"); // Graceful shutdown cleanup diff --git a/platforms/cerberus/client/src/services/CerberusTriggerService.ts b/platforms/cerberus/client/src/services/CerberusTriggerService.ts index 6d3b5ffa6..293cfe8ce 100644 --- a/platforms/cerberus/client/src/services/CerberusTriggerService.ts +++ b/platforms/cerberus/client/src/services/CerberusTriggerService.ts @@ -112,6 +112,26 @@ export class CerberusTriggerService { } } + /** + * Check if a message is a Cerberus system message (including "check skipped" messages) + */ + private isCerberusSystemMessage(message: Message): boolean { + return message.isSystemMessage && message.text.startsWith('$$system-message$$ Cerberus:'); + } + + /** + * Find the last message in the group that isn't the current trigger message + */ + private findLastNonTriggerMessage(messages: Message[], triggerMessageId: string): Message | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.id !== triggerMessageId && !this.isCerberusTrigger(msg.text)) { + return msg; + } + } + return null; + } + /** * Get the last message sent by Cerberus in a group */ @@ -536,9 +556,18 @@ Be thorough and justify your reasoning. Provide clear, actionable recommendation return; } + // If the last message was a Cerberus system message or a "check skipped" message, + // silently return without sending any message to avoid stacking Cerberus messages + const allGroupMessages = await this.messageService.getGroupMessages(triggerMessage.group.id); + const lastNonTriggerMessage = this.findLastNonTriggerMessage(allGroupMessages, triggerMessage.id); + if (lastNonTriggerMessage && this.isCerberusSystemMessage(lastNonTriggerMessage)) { + console.log(`⏭️ Skipping Cerberus check for group ${triggerMessage.group.id} - previous message was a Cerberus system message`); + return; + } + // Get messages since last Cerberus message const messages = await this.getMessagesSinceLastCerberus( - triggerMessage.group.id, + triggerMessage.group.id, triggerMessage.id ); diff --git a/platforms/ereputation/api/src/controllers/WebhookController.ts b/platforms/ereputation/api/src/controllers/WebhookController.ts index bb2e269b2..61e3d34ab 100644 --- a/platforms/ereputation/api/src/controllers/WebhookController.ts +++ b/platforms/ereputation/api/src/controllers/WebhookController.ts @@ -257,11 +257,12 @@ export class WebhookController { poll.mode = local.data.mode as "normal" | "point" | "rank"; poll.visibility = local.data.visibility as "public" | "private"; poll.votingWeight = (local.data.votingWeight || "1p1v") as "1p1v" | "ereputation"; - poll.options = Array.isArray(local.data.options) - ? local.data.options + poll.options = Array.isArray(local.data.options) + ? local.data.options : (local.data.options as string).split(","); poll.deadline = local.data.deadline ? new Date(local.data.deadline as string) : null; poll.groupId = groupId; + poll.customPrompt = (local.data.customPrompt as string) || null; await pollRepository.save(poll); finalLocalId = poll.id; @@ -280,11 +281,12 @@ export class WebhookController { mode: local.data.mode as "normal" | "point" | "rank", visibility: local.data.visibility as "public" | "private", votingWeight: (local.data.votingWeight || "1p1v") as "1p1v" | "ereputation", - options: Array.isArray(local.data.options) - ? local.data.options + options: Array.isArray(local.data.options) + ? local.data.options : (local.data.options as string).split(","), deadline: local.data.deadline ? new Date(local.data.deadline as string) : null, - groupId: groupId + groupId: groupId, + customPrompt: (local.data.customPrompt as string) || null }); const savedPoll = await pollRepository.save(poll); @@ -384,10 +386,12 @@ export class WebhookController { const group = await this.groupService.getGroupById(poll.groupId); if (!group) return; - const charter = (group.charter && group.charter.trim()) ? group.charter : ""; + const evaluationCriteria = (poll.customPrompt && poll.customPrompt.trim()) + ? poll.customPrompt + : (group.charter && group.charter.trim()) ? group.charter : ""; const reputationResults = await this.votingReputationService.calculateGroupMemberReputations( poll.groupId, - charter + evaluationCriteria ); const voteReputationResult = await this.votingReputationService.saveReputationResults( diff --git a/platforms/ereputation/api/src/database/entities/Poll.ts b/platforms/ereputation/api/src/database/entities/Poll.ts index 63cdcee0d..3e113ca1a 100644 --- a/platforms/ereputation/api/src/database/entities/Poll.ts +++ b/platforms/ereputation/api/src/database/entities/Poll.ts @@ -46,6 +46,9 @@ export class Poll { @Column("uuid", { nullable: true }) groupId!: string | null; // Group this poll belongs to + @Column("text", { nullable: true }) + customPrompt!: string | null; + @OneToMany( () => Vote, (vote) => vote.poll, diff --git a/platforms/ereputation/api/src/database/migrations/1775035663491-add-custom-prompt.ts b/platforms/ereputation/api/src/database/migrations/1775035663491-add-custom-prompt.ts new file mode 100644 index 000000000..dd5f28eca --- /dev/null +++ b/platforms/ereputation/api/src/database/migrations/1775035663491-add-custom-prompt.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCustomPrompt1775035663491 implements MigrationInterface { + name = 'AddCustomPrompt1775035663491' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "polls" ADD "customPrompt" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "polls" DROP COLUMN "customPrompt"`); + } +} diff --git a/platforms/ereputation/api/src/services/VotingReputationService.ts b/platforms/ereputation/api/src/services/VotingReputationService.ts index 124ce16e8..c93884d31 100644 --- a/platforms/ereputation/api/src/services/VotingReputationService.ts +++ b/platforms/ereputation/api/src/services/VotingReputationService.ts @@ -169,11 +169,11 @@ export class VotingReputationService { // Call OpenAI once for all members const response = await this.openai.chat.completions.create({ - model: "gpt-4", + model: "gpt-4o", messages: [ { role: "system", - content: "You are an expert reputation analyst for voting systems. Analyze the group charter and references to calculate reputation scores for voting purposes. Always respond with valid JSON containing an array of results, each with ename (user's ename identifier), score (1-5), and a one-sentence justification." + content: "You are an expert reputation analyst for voting systems. Analyze the evaluation criteria and references to calculate reputation scores for voting purposes. Always respond with valid JSON containing an array of results, each with ename (user's ename identifier), score (1-5), and a one-sentence justification." }, { role: "user", @@ -196,7 +196,12 @@ export class VotingReputationService { let result; try { - result = JSON.parse(aiResponseContent); + // Strip markdown code fences if present (e.g. ```json ... ```) + let jsonContent = aiResponseContent.trim(); + if (jsonContent.startsWith("```")) { + jsonContent = jsonContent.replace(/^```(?:json)?\s*/, "").replace(/```\s*$/, "").trim(); + } + result = JSON.parse(jsonContent); console.log(` → Successfully parsed JSON response`); console.log(` Results array length: ${Array.isArray(result) ? result.length : 'not an array'}`); } catch (parseError) { @@ -302,7 +307,7 @@ export class VotingReputationService { // Call OpenAI const response = await this.openai.chat.completions.create({ - model: "gpt-4", + model: "gpt-4o", messages: [ { role: "system", @@ -401,19 +406,19 @@ ${refsText}`; return ` You are analyzing the reputation of multiple users for voting purposes within a group. -GROUP CHARTER: +EVALUATION CRITERIA: ${charter} USERS AND THEIR REFERENCES: ${membersCSV} TASK: -Based on the group charter and the references provided, calculate a reputation score from 1-5 for EACH user that will be used for weighted voting. +Based on the evaluation criteria and the references provided, calculate a reputation score from 1-5 for EACH user that will be used for weighted voting. -IMPORTANT: +IMPORTANT: - Each score must be between 1 and 5 (inclusive) -- Consider how well the references align with the group's charter and values -- Focus on voting-relevant reputation factors mentioned in the charter +- Consider how well the references align with the evaluation criteria and values +- Focus on voting-relevant reputation factors mentioned in the evaluation criteria - Provide a ONE SENTENCE justification explaining each score Respond with a JSON array in this exact format: diff --git a/platforms/ereputation/api/src/web3adapter/mappings/poll.mapping.json b/platforms/ereputation/api/src/web3adapter/mappings/poll.mapping.json index 6f617d78e..0fdee35f1 100644 --- a/platforms/ereputation/api/src/web3adapter/mappings/poll.mapping.json +++ b/platforms/ereputation/api/src/web3adapter/mappings/poll.mapping.json @@ -11,6 +11,7 @@ "deadline": "deadline", "creatorId": "creatorId", "group": "groups(group.id),group", + "customPrompt": "customPrompt", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/evoting/api/src/controllers/PollController.ts b/platforms/evoting/api/src/controllers/PollController.ts index e5d1f26aa..f59dce7ac 100644 --- a/platforms/evoting/api/src/controllers/PollController.ts +++ b/platforms/evoting/api/src/controllers/PollController.ts @@ -53,10 +53,10 @@ export class PollController { createPoll = async (req: Request, res: Response) => { try { console.log('🔍 Full request body:', req.body); - const { title, mode, visibility, votingWeight, options, deadline, groupId } = req.body; + const { title, mode, visibility, votingWeight, options, deadline, groupId, customPrompt } = req.body; const creatorId = (req as any).user.id; - - console.log('🔍 Extracted data:', { title, mode, visibility, votingWeight, options, deadline, groupId, creatorId }); + + console.log('🔍 Extracted data:', { title, mode, visibility, votingWeight, options, deadline, groupId, customPrompt, creatorId }); console.log('🔍 groupId type:', typeof groupId, 'value:', groupId); // groupId is optional - only required for system messages @@ -69,7 +69,8 @@ export class PollController { options, deadline, creatorId, - groupId + groupId, + customPrompt }); console.log('🔍 Created poll:', poll); diff --git a/platforms/evoting/api/src/database/entities/Poll.ts b/platforms/evoting/api/src/database/entities/Poll.ts index 1cf453984..e305cd725 100644 --- a/platforms/evoting/api/src/database/entities/Poll.ts +++ b/platforms/evoting/api/src/database/entities/Poll.ts @@ -62,6 +62,9 @@ export class Poll { @Column("uuid", { nullable: true }) groupId!: string | null; // Group this poll belongs to + @Column("text", { nullable: true }) + customPrompt!: string | null; + @OneToMany( () => Vote, (vote) => vote.poll, diff --git a/platforms/evoting/api/src/database/migrations/1775035663491-add-custom-prompt.ts b/platforms/evoting/api/src/database/migrations/1775035663491-add-custom-prompt.ts new file mode 100644 index 000000000..dd5f28eca --- /dev/null +++ b/platforms/evoting/api/src/database/migrations/1775035663491-add-custom-prompt.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCustomPrompt1775035663491 implements MigrationInterface { + name = 'AddCustomPrompt1775035663491' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "polls" ADD "customPrompt" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "polls" DROP COLUMN "customPrompt"`); + } +} diff --git a/platforms/evoting/api/src/services/PollService.ts b/platforms/evoting/api/src/services/PollService.ts index b1c505b0d..d8f8a241d 100644 --- a/platforms/evoting/api/src/services/PollService.ts +++ b/platforms/evoting/api/src/services/PollService.ts @@ -181,6 +181,7 @@ export class PollService { deadline?: string; creatorId: string; groupId?: string; // Optional groupId for system messages + customPrompt?: string; }): Promise { console.log('🔍 PollService.createPoll called with:', pollData); @@ -229,7 +230,8 @@ export class PollService { deadline: hasDeadline ? new Date(pollData.deadline!) : null, creator, creatorId: pollData.creatorId, - groupId: pollData.groupId || null + groupId: pollData.groupId || null, + customPrompt: pollData.customPrompt || null }; console.log('🔍 Creating poll entity with data:', pollDataForEntity); diff --git a/platforms/evoting/api/src/web3adapter/mappings/poll.mapping.json b/platforms/evoting/api/src/web3adapter/mappings/poll.mapping.json index db4752825..ff9f2c345 100644 --- a/platforms/evoting/api/src/web3adapter/mappings/poll.mapping.json +++ b/platforms/evoting/api/src/web3adapter/mappings/poll.mapping.json @@ -12,6 +12,7 @@ "deadline": "deadline", "creatorId": "creatorId", "group": "groups(group.id),group", + "customPrompt": "customPrompt", "createdAt": "createdAt", "updatedAt": "updatedAt" }, diff --git a/platforms/evoting/client/src/app/(app)/create/page.tsx b/platforms/evoting/client/src/app/(app)/create/page.tsx index 9753d85ff..6a5d06b6e 100644 --- a/platforms/evoting/client/src/app/(app)/create/page.tsx +++ b/platforms/evoting/client/src/app/(app)/create/page.tsx @@ -34,6 +34,7 @@ const createPollSchema = z.object({ return true; }, "Please select a valid group"), votingWeight: z.enum(["1p1v", "ereputation"]).default("1p1v"), + customPrompt: z.string().optional(), options: z .array(z.string() .min(1, "Option cannot be empty") @@ -74,6 +75,7 @@ export default function CreatePoll() { visibility: "public", groupId: "", votingWeight: "1p1v", + customPrompt: "", options: ["", ""], deadline: "", }, @@ -219,7 +221,8 @@ export default function CreatePoll() { votingWeight: data.votingWeight, groupId: data.groupId, options: data.options.filter(option => option.trim() !== ""), - ...(utcDeadline ? { deadline: utcDeadline } : {}) + ...(utcDeadline ? { deadline: utcDeadline } : {}), + ...(data.customPrompt?.trim() ? { customPrompt: data.customPrompt.trim() } : {}) }); toast({ @@ -597,9 +600,22 @@ export default function CreatePoll() { {watchedVotingWeight === "ereputation" && ( -

- Votes will be weighted by each voter's eReputation score. -

+
+

+ Votes will be weighted by each voter's eReputation score. +

+ +