diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9cdf6fc..71c3fc5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,13 @@ model SybilScore { @@index([compositeScore]) } +model SybilExplanation { + id String @id @default(uuid()) + sybilScoreId String @unique + explanation String + createdAt DateTime @default(now()) + + sybilScore SybilScore @relation(fields: [sybilScoreId], references: [id], onDelete: Cascade) model WorldIdVerification { id String @id @default(uuid()) verifiedAt DateTime @default(now()) diff --git a/src/sybil-resistance/sybil-resistance.service.spec.ts b/src/sybil-resistance/sybil-resistance.service.spec.ts index 8232d8c..9528390 100644 --- a/src/sybil-resistance/sybil-resistance.service.spec.ts +++ b/src/sybil-resistance/sybil-resistance.service.spec.ts @@ -44,6 +44,10 @@ describe('SybilResistanceService', () => { findFirst: jest.fn(), findMany: jest.fn(), }, + sybilExplanation: { + create: jest.fn(), + findFirst: jest.fn(), + }, }, }, { @@ -167,10 +171,14 @@ describe('SybilResistanceService', () => { }; jest.spyOn(prisma.sybilScore, 'create').mockResolvedValueOnce(mockScoreRecord); + jest.spyOn(prisma.sybilExplanation, 'create').mockResolvedValueOnce({ id: 'ex-1', sybilScoreId: 'score-1', explanation: 'exp' }); const result = await service.recordSybilScore(mockUserId); expect(prisma.sybilScore.create).toHaveBeenCalled(); + expect(prisma.sybilExplanation.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ sybilScoreId: 'score-1' }) }), + ); expect(result.userId).toBe(mockUserId); expect(result.compositeScore).toBeDefined(); }); @@ -419,6 +427,29 @@ describe('SybilResistanceService', () => { expect(result.details).toBeDefined(); }); + it('should load explanation from SybilExplanation if not present in calculationDetails', async () => { + const mockScore = { + id: 'score-1', + userId: mockUserId, + compositeScore: 0.57, + worldcoinScore: 1.0, + walletAgeScore: 0.67, + stakingScore: 0.0, + accuracyScore: 0.0, + calculationDetails: JSON.stringify({ componentScores: { worldcoin: 1.0 } }), + createdAt: new Date(), + updatedAt: new Date(), + }; + + jest.spyOn(prisma.sybilScore, 'findFirst').mockResolvedValueOnce(mockScore); + jest.spyOn(prisma.sybilExplanation, 'findFirst').mockResolvedValueOnce({ id: 'ex-1', sybilScoreId: 'score-1', explanation: 'Stored explanation' }); + + const result = await service.getSybilScoreForVoting(mockUserId); + + expect(result.details).toBeDefined(); + expect(result.details.explanation).toBe('Stored explanation'); + }); + it('should indicate unverified status correctly', async () => { const mockScore = { id: 'score-1', diff --git a/src/sybil-resistance/sybil-resistance.service.ts b/src/sybil-resistance/sybil-resistance.service.ts index 26c3d59..a6d60aa 100644 --- a/src/sybil-resistance/sybil-resistance.service.ts +++ b/src/sybil-resistance/sybil-resistance.service.ts @@ -229,7 +229,12 @@ Final score: ${Number(composite.toFixed(4))} (weighted average)`, async recordSybilScore(userId: string): Promise { const { score: compositeScore, details } = await this.computeSybilScore(userId); - return this.prisma.sybilScore.create({ + // Persist SybilScore without the potentially large `explanation` text + const detailsCopy: any = { ...details }; + const explanationText = detailsCopy.explanation; + delete detailsCopy.explanation; + + const scoreRecord = await this.prisma.sybilScore.create({ data: { userId, worldcoinScore: details.componentScores.worldcoin, @@ -237,9 +242,21 @@ Final score: ${Number(composite.toFixed(4))} (weighted average)`, stakingScore: details.componentScores.staking, accuracyScore: details.componentScores.accuracy, compositeScore, - calculationDetails: JSON.stringify(details), + calculationDetails: JSON.stringify(detailsCopy), }, }); + + // Store explanation separately to avoid huge JSON columns + if (explanationText) { + await this.prisma.sybilExplanation.create({ + data: { + sybilScoreId: scoreRecord.id, + explanation: explanationText, + }, + }); + } + + return scoreRecord; } /** @@ -369,11 +386,25 @@ Final score: ${Number(composite.toFixed(4))} (weighted average)`, }> { const score = await this.getLatestSybilScore(userId); + // Parse calculation details and, if explanation was stored separately, load it + let details = score.calculationDetails ? JSON.parse(score.calculationDetails) : null; + if (details && !details.explanation) { + // try to load explanation from separate table + try { + const expl = await this.prisma.sybilExplanation.findFirst({ where: { sybilScoreId: score.id } }); + if (expl && expl.explanation) { + details.explanation = expl.explanation; + } + } catch (err) { + // ignore missing explanation + } + } + return { userId, score: score.compositeScore, isVerified: score.worldcoinScore > 0, - details: score.calculationDetails ? JSON.parse(score.calculationDetails) : null, + details, }; } }