diff --git a/prisma/migrations/20230317171159_adds_possiblity_of_multiple_answers_to_one_card/migration.sql b/prisma/migrations/20230317171159_adds_possiblity_of_multiple_answers_to_one_card/migration.sql
new file mode 100644
index 0000000..7189663
--- /dev/null
+++ b/prisma/migrations/20230317171159_adds_possiblity_of_multiple_answers_to_one_card/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `answer` on the `Card` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "Card" DROP COLUMN "answer",
+ADD COLUMN "validAnswers" TEXT[];
diff --git a/prisma/migrations/20230317201422_adds_answer_validation_report_model/migration.sql b/prisma/migrations/20230317201422_adds_answer_validation_report_model/migration.sql
new file mode 100644
index 0000000..8496c51
--- /dev/null
+++ b/prisma/migrations/20230317201422_adds_answer_validation_report_model/migration.sql
@@ -0,0 +1,17 @@
+-- CreateTable
+CREATE TABLE "AnswerValidationReport" (
+ "id" TEXT NOT NULL,
+ "cardId" TEXT NOT NULL,
+ "userId" TEXT,
+ "answer" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AnswerValidationReport_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "AnswerValidationReport" ADD CONSTRAINT "AnswerValidationReport_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "Card"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AnswerValidationReport" ADD CONSTRAINT "AnswerValidationReport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20230318184925_adds_answer_validation_status_field/migration.sql b/prisma/migrations/20230318184925_adds_answer_validation_status_field/migration.sql
new file mode 100644
index 0000000..4ddaceb
--- /dev/null
+++ b/prisma/migrations/20230318184925_adds_answer_validation_status_field/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "AnswerValidationReportStatus" AS ENUM ('Pending', 'Accepted', 'Rejected');
+
+-- AlterTable
+ALTER TABLE "AnswerValidationReport" ADD COLUMN "status" "AnswerValidationReportStatus" NOT NULL DEFAULT 'Pending';
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index da1da52..e03310a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -34,18 +34,19 @@ model Session {
}
model User {
- id String @id @default(cuid())
- name String?
- email String? @unique
- emailVerified DateTime?
- image String?
- accounts Account[]
- sessions Session[]
- decks Deck[]
- studySessions StudySession[]
- favorites Favorite[]
- description String?
- topics Topic[]
+ id String @id @default(cuid())
+ name String?
+ email String? @unique
+ emailVerified DateTime?
+ image String?
+ accounts Account[]
+ sessions Session[]
+ decks Deck[]
+ studySessions StudySession[]
+ favorites Favorite[]
+ description String?
+ topics Topic[]
+ answerValidationReports AnswerValidationReport[]
}
model VerificationToken {
@@ -62,6 +63,12 @@ enum Visibility {
WithLink
}
+enum AnswerValidationReportStatus {
+ Pending
+ Accepted
+ Rejected
+}
+
model Deck {
id String @id @default(cuid())
title String
@@ -88,15 +95,28 @@ model Topic {
}
model Card {
- id String @id @default(cuid())
- question String
- answer String
- deckId String
- deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
- createdAt DateTime @default(now())
- updatedAt DateTime @default(now()) @updatedAt
- studySessionBoxCards StudySessionBoxCard[]
- isAiPowered Boolean @default(false)
+ id String @id @default(cuid())
+ question String
+ validAnswers String[]
+ deckId String
+ deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+ studySessionBoxCards StudySessionBoxCard[]
+ isAiPowered Boolean @default(false)
+ answerValidationReports AnswerValidationReport[]
+}
+
+model AnswerValidationReport {
+ id String @id @default(cuid())
+ cardId String
+ card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
+ userId String?
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+ answer String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+ status AnswerValidationReportStatus @default(Pending)
}
model StudySession {
diff --git a/src/components/modal/index.ts b/src/components/modal/index.ts
index a71686b..8bc91ae 100644
--- a/src/components/modal/index.ts
+++ b/src/components/modal/index.ts
@@ -1,9 +1,11 @@
import { BaseModal } from './base/base-modal.component'
import { NewCardModal } from './new-card/new-card-modal.component'
import { NewTopicModal } from './new-topic/new-topic-modal.component'
+import { ReportAnswerValidationModal } from './report-answer-validation/report-answer-validation.component'
export const Modal = {
Base: BaseModal,
NewTopic: NewTopicModal,
NewCard: NewCardModal,
+ ReportAnswerValidation: ReportAnswerValidationModal,
}
diff --git a/src/components/modal/new-card/new-card-modal.component.tsx b/src/components/modal/new-card/new-card-modal.component.tsx
index fa0a82d..e5a137a 100644
--- a/src/components/modal/new-card/new-card-modal.component.tsx
+++ b/src/components/modal/new-card/new-card-modal.component.tsx
@@ -52,8 +52,9 @@ export function NewCardModal(props: NewCardModalProps) {
@@ -209,6 +233,17 @@ const ReviewDeckPage: WithAuthentication<
)
}
+ const renderModal = () => {
+ return (
+
+ )
+ }
+
return (
<>
@@ -218,6 +253,7 @@ const ReviewDeckPage: WithAuthentication<
{renderContent()}
+ {renderModal()}
>
)
}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index a3b3a85..62fd334 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -1,3 +1,4 @@
+import { answerValidationReportsRouter } from './routers/answer-validation-reports'
import { cardsRouter } from './routers/cards'
import { decksRouter } from './routers/decks'
import { filesRouter } from './routers/files'
@@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({
studySession: studySessionRouter,
user: userRouter,
cards: cardsRouter,
+ answerValidationReports: answerValidationReportsRouter,
})
export type AppRouter = typeof appRouter
diff --git a/src/server/api/routers/answer-validation-reports/answer-validation-reports.router.ts b/src/server/api/routers/answer-validation-reports/answer-validation-reports.router.ts
new file mode 100644
index 0000000..21cb864
--- /dev/null
+++ b/src/server/api/routers/answer-validation-reports/answer-validation-reports.router.ts
@@ -0,0 +1,149 @@
+import { AnswerValidationReportStatus } from '@prisma/client'
+import { TRPCError } from '@trpc/server'
+import { z } from 'zod'
+
+import { MAX_VALID_ANSWERS_PER_CARD } from '~/constants'
+import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'
+
+export const answerValidationReportsRouter = createTRPCRouter({
+ reportAnswerValidation: protectedProcedure
+ .input(
+ z.object({
+ answer: z.string().min(1),
+ cardId: z.string().min(1),
+ }),
+ )
+ .mutation(({ input: { answer, cardId }, ctx }) => {
+ /** TODO emiliosheinz: Add more validations such as:
+ * - Check if the user has a study session withing the card's deck
+ * - Check if the user has already reported the answer
+ * ...
+ */
+ return ctx.prisma.answerValidationReport.create({
+ data: {
+ answer,
+ cardId,
+ userId: ctx.session.user.id,
+ },
+ })
+ }),
+ getCardsWithAnswerValidationReports: protectedProcedure
+ .input(z.object({ deckId: z.string().min(1) }))
+ .query(async ({ input: { deckId }, ctx }) => {
+ const deck = await ctx.prisma.deck.findFirst({
+ where: { id: deckId, ownerId: ctx.session.user.id },
+ select: {
+ title: true,
+ id: true,
+ cards: {
+ where: {
+ answerValidationReports: {
+ some: {
+ status: AnswerValidationReportStatus.Pending,
+ },
+ },
+ },
+ select: {
+ id: true,
+ question: true,
+ answerValidationReports: {
+ where: { status: AnswerValidationReportStatus.Pending },
+ orderBy: { createdAt: 'asc' },
+ },
+ },
+ },
+ },
+ })
+
+ if (!deck) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Deck não foi encontrado',
+ })
+ }
+
+ return deck
+ }),
+ updateAnswerValidationReportStatus: protectedProcedure
+ .input(
+ z.object({
+ answerValidationReportId: z.string().min(1),
+ status: z.nativeEnum(AnswerValidationReportStatus),
+ }),
+ )
+ .mutation(async ({ input: { answerValidationReportId, status }, ctx }) => {
+ const answerValidationReport =
+ await ctx.prisma.answerValidationReport.findFirst({
+ where: { id: answerValidationReportId },
+ })
+
+ if (!answerValidationReport) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Report não foi encontrado',
+ })
+ }
+
+ if (
+ answerValidationReport.status !== AnswerValidationReportStatus.Pending
+ ) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Report já foi aceito ou recusado',
+ })
+ }
+
+ const updateReport = () =>
+ ctx.prisma.answerValidationReport.update({
+ where: { id: answerValidationReportId },
+ data: { status },
+ })
+
+ if (status === AnswerValidationReportStatus.Accepted) {
+ const card = await ctx.prisma.card.findFirst({
+ where: { id: answerValidationReport.cardId },
+ })
+
+ if (!card) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Card não foi encontrado',
+ })
+ }
+
+ if (card.validAnswers.length > MAX_VALID_ANSWERS_PER_CARD) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: `Card já possui ${MAX_VALID_ANSWERS_PER_CARD} respostas válidas. Vá até o Deck e remova uma resposta válida antes de adicionar uma nova.`,
+ })
+ }
+
+ const updateCard = () =>
+ ctx.prisma.card.update({
+ where: { id: card.id },
+ data: {
+ validAnswers: [
+ ...card.validAnswers,
+ answerValidationReport.answer,
+ ],
+ },
+ })
+
+ await Promise.all([updateReport(), updateCard()])
+ } else {
+ await updateReport()
+ }
+ }),
+ hasDeckPendingAnswerValidationReports: protectedProcedure
+ .input(z.object({ deckId: z.string().min(1) }))
+ .query(async ({ input: { deckId }, ctx }) => {
+ const count = await ctx.prisma.answerValidationReport.count({
+ where: {
+ card: { deckId },
+ status: AnswerValidationReportStatus.Pending,
+ },
+ })
+
+ return count > 0
+ }),
+})
diff --git a/src/server/api/routers/answer-validation-reports/index.ts b/src/server/api/routers/answer-validation-reports/index.ts
new file mode 100644
index 0000000..a6dc75d
--- /dev/null
+++ b/src/server/api/routers/answer-validation-reports/index.ts
@@ -0,0 +1 @@
+export { answerValidationReportsRouter } from './answer-validation-reports.router'
diff --git a/src/server/api/routers/decks/decks.router.ts b/src/server/api/routers/decks/decks.router.ts
index f5bf734..98c9d34 100644
--- a/src/server/api/routers/decks/decks.router.ts
+++ b/src/server/api/routers/decks/decks.router.ts
@@ -28,7 +28,12 @@ export const decksRouter = createTRPCRouter({
}
}),
},
- cards: { create: cards },
+ cards: {
+ create: cards.map(card => ({
+ ...card,
+ validAnswers: card.validAnswers.split(';'),
+ })),
+ },
},
})
}),
@@ -63,6 +68,7 @@ export const decksRouter = createTRPCRouter({
delete: deletedCards?.map(({ id }) => ({ id })),
create: newCards?.map(card => ({
...card,
+ validAnswers: card.validAnswers.split(';'),
studySessionBoxCards: {
create: studySessionBoxes.map(box => ({
studySessionBoxId: box.id,
@@ -71,7 +77,10 @@ export const decksRouter = createTRPCRouter({
})),
update: editedCards?.map(({ id, ...card }) => ({
where: { id },
- data: card,
+ data: {
+ ...card,
+ validAnswers: card.validAnswers.split(';'),
+ },
})),
},
topics: {
diff --git a/src/server/api/routers/study-session/study-session.router.ts b/src/server/api/routers/study-session/study-session.router.ts
index 63ea13d..e292ed7 100644
--- a/src/server/api/routers/study-session/study-session.router.ts
+++ b/src/server/api/routers/study-session/study-session.router.ts
@@ -2,14 +2,14 @@ import { Visibility } from '@prisma/client'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
-import { MINIMUM_ACCEPTED_SIMILARITY, STUDY_SESSION_BOXES } from '~/constants'
+import { STUDY_SESSION_BOXES } from '~/constants'
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '~/server/api/trpc'
import { addHours, differenceInHours } from '~/utils/date-time'
-import { verifyStringsSimilarity } from '~/utils/openai'
+import { verifyIfAnswerIsRight } from '~/utils/openai'
export const studySessionRouter = createTRPCRouter({
create: protectedProcedure
@@ -163,7 +163,7 @@ export const studySessionRouter = createTRPCRouter({
select: {
id: true,
card: {
- select: { question: true },
+ select: { question: true, id: true },
},
studySessionAttempts: {
orderBy: { createdAt: 'desc' },
@@ -223,7 +223,8 @@ export const studySessionRouter = createTRPCRouter({
return !lastAttempt || !lastReview || lastAttempt < lastReview
})
.map(boxCard => ({
- id: boxCard.id,
+ id: boxCard.card.id,
+ boxCardId: boxCard.id,
question: boxCard.card.question,
})),
}),
@@ -296,11 +297,13 @@ export const studySessionRouter = createTRPCRouter({
data: { studySessionBoxId: firstStudySessionBox.id },
})
- return { isRight: false, answer: card.answer }
+ return { isRight: false, answer: card.validAnswers.join('; ') }
}
- const similarity = await verifyStringsSimilarity(card.answer, answer)
- const isAnswerRight = similarity > MINIMUM_ACCEPTED_SIMILARITY
+ const isAnswerRight = await verifyIfAnswerIsRight(
+ answer,
+ card.validAnswers,
+ )
let updateBoxCard
const addNewAttempt = ctx.prisma.studySessionAttempt.create({
@@ -341,7 +344,7 @@ export const studySessionRouter = createTRPCRouter({
await Promise.all([addNewAttempt, updateBoxCard])
- return { isRight: isAnswerRight, answer: card.answer }
+ return { isRight: isAnswerRight, answer: card.validAnswers.join('; ') }
}),
finishReviewSession: protectedProcedure
.input(
diff --git a/src/utils/navigation/index.ts b/src/utils/navigation/index.ts
index a5c9188..026b615 100644
--- a/src/utils/navigation/index.ts
+++ b/src/utils/navigation/index.ts
@@ -10,4 +10,5 @@ export const routes = {
profileSettings: () => '/profile/settings',
decksForYou: () => '/decks/for-you',
favorites: () => '/decks/favorites',
+ answerValidationReports: (id: string) => `/decks/reports/${id}`,
}
diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts
index c0c8b4e..9ebf25e 100644
--- a/src/utils/openai/index.ts
+++ b/src/utils/openai/index.ts
@@ -4,6 +4,7 @@
import calculateSimilarity from 'compute-cosine-similarity'
import { Configuration, OpenAIApi } from 'openai'
+import { MINIMUM_ACCEPTED_SIMILARITY } from '~/constants'
import type {
CardInput,
TopicInput,
@@ -23,7 +24,7 @@ const createEmbedding = (str: string) =>
input: str.replace('\n', ' '),
},
{
- timeout: 15_000,
+ timeout: 10_000,
},
)
@@ -34,22 +35,34 @@ const trimAndRemoveDoubleQuotes = (str: string) =>
* Embed both strings with text-embedding-ada-002 and calculate their distance with cosine similarity
* Reference: https://platform.openai.com/docs/guides/embeddings/limitations-risks
*/
-export async function verifyStringsSimilarity(str1: string, str2: string) {
- const [firstStringResponse, secondStringResponse] = await Promise.all([
- createEmbedding(str1),
- createEmbedding(str2),
+export async function verifyIfAnswerIsRight(
+ actualAnswer: string,
+ validAnswers: Array,
+) {
+ const [embedActualAnswer, ...validAnswersEmbeddings] = await Promise.all([
+ createEmbedding(actualAnswer),
+ ...validAnswers.map(answer => createEmbedding(answer)),
])
- const firstResult = firstStringResponse.data.data[0]?.embedding
- const secondResult = secondStringResponse.data.data[0]?.embedding
+ const actualAnswerResult = embedActualAnswer.data.data[0]?.embedding
+ const validAnswersResults = validAnswersEmbeddings
+ .map(({ data }) => data.data[0]?.embedding)
+ .filter(Boolean)
- if (!firstResult || !secondResult) {
+ if (!actualAnswerResult || validAnswersResults.length === 0) {
throw new Error('Could not calculate similarity. No results where found.')
}
- const similarity = calculateSimilarity(firstResult, secondResult)
+ const isRight = validAnswersResults.some(validAnswerResult => {
+ const similarity = calculateSimilarity(
+ actualAnswerResult,
+ validAnswerResult || [],
+ )
- return similarity
+ return similarity > MINIMUM_ACCEPTED_SIMILARITY
+ })
+
+ return isRight
}
type GenerateFlashCardsParam = {
@@ -61,41 +74,51 @@ export async function generateFlashCards({
topics,
title,
}: GenerateFlashCardsParam): Promise> {
- const amountOfCards = 3
- const charactersPerSentence = 65
-
- /** Build topics strings */
- const joinedTopics = topics.map(({ title }) => title).join(', ')
-
- /** Build prompt asking OpenAI to generate a csv string */
- const prompt = `Levando em conta o contexto ${title}, gere um Array JSON com ${amountOfCards} perguntas e respostas curtas e diretas, de no máximo ${charactersPerSentence} caracteres, sobre ${joinedTopics}. [{question: "pergunta", answer: "resposta"}, ...]`
-
- const response = await openai.createChatCompletion(
- {
- n: 1,
- messages: [{ role: 'user', content: prompt }],
- temperature: 0.8,
- model: 'gpt-3.5-turbo',
- max_tokens: amountOfCards * charactersPerSentence,
- },
- { timeout: 15_000 },
- )
-
- const generatedJsonString = response.data.choices[0]?.message?.content
-
- if (!generatedJsonString) {
- throw new Error('Não foi possível gerar as perguntas e respostas.')
+ let generatedJsonString: string | undefined
+
+ try {
+ const amountOfCards = 3
+ const charactersPerSentence = 65
+
+ /** Build topics strings */
+ const joinedTopics = topics.map(({ title }) => title).join(' ou ')
+
+ /** Build prompt asking OpenAI to generate a csv string */
+ const prompt = `Levando em conta o contexto ${title}, gere um Array JSON de tamanho ${amountOfCards} com perguntas e respostas curtas e diretas, de no máximo ${charactersPerSentence} caracteres, sobre ${joinedTopics}. [{question: "pergunta", answer: "resposta"}, ...]`
+
+ const response = await openai.createChatCompletion(
+ {
+ n: 1,
+ messages: [{ role: 'user', content: prompt }],
+ temperature: 0.8,
+ model: 'gpt-3.5-turbo',
+ max_tokens: amountOfCards * charactersPerSentence,
+ },
+ { timeout: 30_000 },
+ )
+
+ generatedJsonString = response.data.choices[0]?.message?.content
+
+ if (!generatedJsonString) {
+ throw new Error('Não foi possível gerar as perguntas e respostas.')
+ }
+
+ const generatedJson = JSON.parse(generatedJsonString)
+
+ const cards: Array = generatedJson.map(
+ ({ question, answer }: { question: string; answer: string }) => ({
+ question: trimAndRemoveDoubleQuotes(question),
+ validAnswers: trimAndRemoveDoubleQuotes(answer),
+ isAiPowered: true,
+ }),
+ )
+
+ return cards
+ } catch (error) {
+ /**
+ * Added to improve error tracking on log monitoring tools
+ */
+ console.error(error, generatedJsonString)
+ throw error
}
-
- const generatedJson = JSON.parse(generatedJsonString)
-
- const cards: Array = generatedJson.map(
- ({ question, answer }: { question: string; answer: string }) => ({
- question: trimAndRemoveDoubleQuotes(question),
- answer: trimAndRemoveDoubleQuotes(answer),
- isAiPowered: true,
- }),
- )
-
- return cards
}
diff --git a/src/utils/validators/card.ts b/src/utils/validators/card.ts
index 313fd56..0510dcf 100644
--- a/src/utils/validators/card.ts
+++ b/src/utils/validators/card.ts
@@ -1,5 +1,7 @@
import { z } from 'zod'
+import { MAX_VALID_ANSWERS_PER_CARD } from '~/constants'
+
export const CardInputSchema = z.object({
id: z.string().optional(),
isAiPowered: z.boolean().optional(),
@@ -10,11 +12,20 @@ export const CardInputSchema = z.object({
})
.min(1, { message: 'A pergunta de um Card é obrigatória' })
.max(250, { message: 'A pergunta não pode ter mais que 250 caracteres' }),
- answer: z
+ validAnswers: z
.string({
required_error: 'A resposta de um Card é obrigatória',
invalid_type_error: 'Resposta inválida',
})
.min(1, { message: 'A resposta de um Card é obrigatória' })
- .max(250, { message: 'A resposta não pode ter mais que 250 caracteres' }),
+ .refine(
+ (answers: string) =>
+ answers.split(';').length <= MAX_VALID_ANSWERS_PER_CARD,
+ `Um Card não pode ter mais que ${10} respostas válidas`,
+ )
+ .refine(
+ (answers: string) =>
+ answers.split(';').every(answer => answer.length <= 250),
+ 'Cada resposta não pode ter mais que 250 caracteres',
+ ),
})
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index a7c5522..fa52209 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -20,11 +20,13 @@ module.exports = {
700: '#B91C1C',
500: '#ef4444',
300: '#FCA5A5',
+ 100: '#fee2e2',
50: '#fef2f2',
},
success: {
700: '#15803D',
300: '#86EFAC',
+ 100: '#dcfce7',
},
warning: {
500: '#EAB308',