From 9082e5676a3561de245e1cdafc3d64ac885dd396 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sat, 25 Feb 2023 06:08:32 -0300 Subject: [PATCH 1/9] fix: :bug: fix get last study session --- .../review/hooks/use-deck-review.hook.tsx | 4 ++- src/pages/decks/[id].tsx | 6 +--- .../study-session/study-session.router.ts | 32 +++++++++++++++---- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/modules/decks/review/hooks/use-deck-review.hook.tsx b/src/modules/decks/review/hooks/use-deck-review.hook.tsx index ad0d4db..966fd08 100644 --- a/src/modules/decks/review/hooks/use-deck-review.hook.tsx +++ b/src/modules/decks/review/hooks/use-deck-review.hook.tsx @@ -75,7 +75,9 @@ export function useDeckReview(deckId: string) { }, [cardAnswerStage, answerResult]) useEffect(() => { - if (isLastCard && cardAnswerStage === 'validation' && studySessionId) { + const isLastCardValidation = isLastCard && cardAnswerStage === 'validation' + + if (isLastCardValidation && studySessionId) { finishReviewSession({ studySessionId, reviewedBoxIds: studySessionBoxes?.map(({ id }) => id) ?? [], diff --git a/src/pages/decks/[id].tsx b/src/pages/decks/[id].tsx index 6d21ba5..39982e8 100644 --- a/src/pages/decks/[id].tsx +++ b/src/pages/decks/[id].tsx @@ -108,10 +108,6 @@ const DeckDetailsPage: NextPage< ) } - const renderCurrentStudySessionCard = () => { - return - } - return ( <> @@ -141,7 +137,7 @@ const DeckDetailsPage: NextPage< {renderTopics()} - {renderCurrentStudySessionCard()} +

Cards:

    {deck.cards.map(card => ( 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 466a3bf..50996d6 100644 --- a/src/server/api/routers/study-session/study-session.router.ts +++ b/src/server/api/routers/study-session/study-session.router.ts @@ -81,7 +81,7 @@ export const studySessionRouter = createTRPCRouter({ .query(async ({ input: { deckId }, ctx }) => { if (!ctx.session?.user) return null - const studySessionBoxes = await ctx.prisma.studySessionBox.findMany({ + const findNextReviewBox = ctx.prisma.studySessionBox.findFirst({ where: { studySession: { deckId, @@ -95,22 +95,42 @@ export const studySessionRouter = createTRPCRouter({ { nextReview: 'asc', }, + ], + select: { + nextReview: true, + }, + }) + + const findLastReviewBox = ctx.prisma.studySessionBox.findFirst({ + where: { + studySession: { + deckId, + userId: ctx.session.user.id, + }, + lastReview: { + not: null, + }, + }, + orderBy: [ { lastReview: 'desc', }, ], select: { lastReview: true, - nextReview: true, }, }) - if (studySessionBoxes.length === 0) return null + const [nextReviewBox, lastReviewBox] = await ctx.prisma.$transaction([ + findNextReviewBox, + findLastReviewBox, + ]) + + if (!nextReviewBox) return null return { - nextReviewDate: studySessionBoxes[0]?.nextReview, - lastReviewDate: studySessionBoxes.find(box => !!box.lastReview) - ?.lastReview, + nextReviewDate: nextReviewBox.nextReview, + lastReviewDate: lastReviewBox?.lastReview, } }), getReviewSession: protectedProcedure From 721d8c97ae2d513e51e0b5668b8437208a0467a3 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sat, 25 Feb 2023 16:35:09 -0300 Subject: [PATCH 2/9] feat: :sparkles: add open ai api call to validate cards responses --- .env.example | 3 + additional.d.ts | 6 ++ package.json | 3 +- src/constants/index.ts | 1 + src/env/schema.mjs | 1 + .../study-session/study-session.router.ts | 40 ++++++-- src/utils/openai/index.ts | 44 +++++++++ yarn.lock | 99 ++++++++++++++++++- 8 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 additional.d.ts create mode 100644 src/utils/openai/index.ts diff --git a/.env.example b/.env.example index bf7ac43..321bdf6 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,6 @@ AWS_S3_ACCESS_KEY_ID= AWS_S3_SECRET_ACCESS_KEY= AWS_S3_BUCKET= AWS_CLOUD_FRONT_URL= + +#OpenAI +OPENAI_API_KEY= diff --git a/additional.d.ts b/additional.d.ts new file mode 100644 index 0000000..3158084 --- /dev/null +++ b/additional.d.ts @@ -0,0 +1,6 @@ +declare module 'compute-cosine-similarity' { + export default function calculateSimilarity( + firstVector: number[], + secondVector: number[], + ): number +} diff --git a/package.json b/package.json index a4792fd..bcd551a 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "@trpc/server": "^10.0.0", "@vercel/analytics": "^0.1.6", "compressorjs": "^1.1.1", + "compute-cosine-similarity": "^1.0.0", "jotai": "^1.12.1", "lodash": "^4.17.21", "next": "13.1.1", "next-auth": "^4.18.3", "next-superjson-plugin": "^0.5.4", "nextjs-progressbar": "^0.0.16", + "openai": "^3.1.0", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.41.5", @@ -43,7 +45,6 @@ "react-intersection-observer": "^9.4.2", "react-tooltip": "^5.7.2", "sharp": "^0.31.3", - "string-similarity": "^4.0.4", "superjson": "1.9.1", "tailwindcss-animation-delay": "^1.0.7", "zod": "^3.18.0" diff --git a/src/constants/index.ts b/src/constants/index.ts index aec4e7a..aed920f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,6 +2,7 @@ import { Visibility } from '@prisma/client' import type { Option } from '~/components/radio-group' +export const MINIMUM_ACCEPTED_SIMILARITY = 0.9 export const MAX_TOPICS_PER_DECK_AND_USER = 5 export const ITEMS_PER_PAGE = 30 diff --git a/src/env/schema.mjs b/src/env/schema.mjs index 8b2e074..d1140b7 100644 --- a/src/env/schema.mjs +++ b/src/env/schema.mjs @@ -31,6 +31,7 @@ export const serverSchema = z.object({ AWS_S3_SECRET_ACCESS_KEY: z.string(), AWS_S3_BUCKET: z.string(), AWS_CLOUD_FRONT_URL: z.string().url(), + OPENAI_API_KEY: z.string(), }) /** 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 50996d6..63ea13d 100644 --- a/src/server/api/routers/study-session/study-session.router.ts +++ b/src/server/api/routers/study-session/study-session.router.ts @@ -1,15 +1,15 @@ import { Visibility } from '@prisma/client' import { TRPCError } from '@trpc/server' -import { compareTwoStrings } from 'string-similarity' import { z } from 'zod' -import { STUDY_SESSION_BOXES } from '~/constants' +import { MINIMUM_ACCEPTED_SIMILARITY, 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' export const studySessionRouter = createTRPCRouter({ create: protectedProcedure @@ -138,7 +138,7 @@ export const studySessionRouter = createTRPCRouter({ .query(async ({ input: { deckId }, ctx }) => { const now = new Date() - const currentStudySession = await ctx.prisma.studySession.findFirst({ + const findCurrentStudySession = ctx.prisma.studySession.findFirst({ where: { deckId, userId: ctx.session.user.id }, include: { deck: { @@ -158,7 +158,6 @@ export const studySessionRouter = createTRPCRouter({ }, select: { id: true, - lastReview: true, createdAt: true, studySessionBoxCards: { select: { @@ -177,6 +176,32 @@ export const studySessionRouter = createTRPCRouter({ }, }) + const findLastReviewBox = ctx.prisma.studySessionBox.findFirst({ + where: { + studySession: { + deckId, + userId: ctx.session.user.id, + }, + lastReview: { + not: null, + }, + }, + orderBy: [ + { + lastReview: 'desc', + }, + ], + select: { + lastReview: true, + }, + }) + + const [currentStudySession, lastReviewBox] = + await ctx.prisma.$transaction([ + findCurrentStudySession, + findLastReviewBox, + ]) + if (!currentStudySession) { throw new TRPCError({ code: 'NOT_FOUND', @@ -184,11 +209,13 @@ export const studySessionRouter = createTRPCRouter({ }) } + const lastReview = lastReviewBox?.lastReview + return { deck: currentStudySession.deck, studySessionId: currentStudySession.id, studySessionBoxes: currentStudySession.studySessionBoxes.map( - ({ studySessionBoxCards, id, lastReview }) => ({ + ({ studySessionBoxCards, id }) => ({ id, cards: studySessionBoxCards .filter(boxCard => { @@ -272,7 +299,8 @@ export const studySessionRouter = createTRPCRouter({ return { isRight: false, answer: card.answer } } - const isAnswerRight = compareTwoStrings(answer, card.answer) > 0.8 + const similarity = await verifyStringsSimilarity(card.answer, answer) + const isAnswerRight = similarity > MINIMUM_ACCEPTED_SIMILARITY let updateBoxCard const addNewAttempt = ctx.prisma.studySessionAttempt.create({ diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts new file mode 100644 index 0000000..901a3c1 --- /dev/null +++ b/src/utils/openai/index.ts @@ -0,0 +1,44 @@ +import calculateSimilarity from 'compute-cosine-similarity' +import { Configuration, OpenAIApi } from 'openai' + +import { env } from '~/env/server.mjs' + +import { isServerSide } from '../runtime' + +const configuration = new Configuration({ + apiKey: env.OPENAI_API_KEY, +}) + +const openai = new OpenAIApi(configuration) + +const createEmbedding = (str: string) => + openai.createEmbedding({ + model: 'text-embedding-ada-002', + input: str.replace('\n', ' '), + }) + +/** + * 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) { + if (!isServerSide()) { + throw new Error('Function can only be called server-side.') + } + + const [firstStringResponse, secondStringResponse] = await Promise.all([ + createEmbedding(str1), + createEmbedding(str2), + ]) + + const firstResult = firstStringResponse.data.data[0]?.embedding + const secondResult = secondStringResponse.data.data[0]?.embedding + + if (!firstResult || !secondResult) { + throw new Error('Could not calculate similarity. No results where found.') + } + + const similarity = calculateSimilarity(firstResult, secondResult) + + return similarity +} diff --git a/yarn.lock b/yarn.lock index b758c4a..e5bd1d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2932,6 +2932,11 @@ ast-types-flow@^0.0.7: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + autoprefixer@^10.4.7: version "10.4.13" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.13.tgz#b5136b59930209a321e9fa3dca2e7c4d223e83a8" @@ -2954,6 +2959,13 @@ axe-core@^4.4.3: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.2.tgz#6e566ab2a3d29e415f5115bc0fd2597a5eb3e5e3" integrity sha512-b1WlTV8+XKLj9gZy2DZXgQiyDp9xkkoe2a6U6UbYccScq2wgH/YwCeI2/Jq2mgo0HzQxqJOjWZBLeA/mqsk5Mg== +axios@^0.26.0: + version "0.26.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9" + integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA== + dependencies: + follow-redirects "^1.14.8" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -3414,6 +3426,13 @@ columnify@^1.6.0: strip-ansi "^6.0.1" wcwidth "^1.0.0" +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" @@ -3440,6 +3459,32 @@ compressorjs@^1.1.1: blueimp-canvas-to-blob "^3.29.0" is-blob "^2.1.0" +compute-cosine-similarity@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/compute-cosine-similarity/-/compute-cosine-similarity-1.0.0.tgz#29f2bf2e7baefa231caba40591a31c17b19a4e95" + integrity sha512-jPdqVMT8Im6jS+THoDBDKZlGg32g7QeXOiBXeuiMXVEV5Tg8z38Qpvg08mt2qRy9j/H0m6w/VDoKBJ7w7F/sKg== + dependencies: + compute-dot "^1.1.0" + compute-l2norm "^1.1.0" + validate.io-array "^1.0.5" + validate.io-function "^1.0.2" + +compute-dot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/compute-dot/-/compute-dot-1.1.0.tgz#01a5ba2c7af73b99002acb258459c9576a8232dc" + integrity sha512-L5Ocet4DdMrXboss13K59OK23GXjiSia7+7Ukc7q4Bl+RVpIXK2W9IHMbWDZkh+JUEvJAwOKRaJDiFUa1LTnJg== + dependencies: + validate.io-array "^1.0.3" + validate.io-function "^1.0.2" + +compute-l2norm@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/compute-l2norm/-/compute-l2norm-1.1.0.tgz#bd09131c6b36c8d70c68334e176009a4e0a989ac" + integrity sha512-6EHh1Elj90eU28SXi+h2PLnTQvZmkkHWySpoFz+WOlVNLz3DQoC4ISUHSV9n5jMxPHtKGJ01F4uu2PsXBB8sSg== + dependencies: + validate.io-array "^1.0.3" + validate.io-function "^1.0.2" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -3704,6 +3749,11 @@ del@^6.0.0: rimraf "^3.0.2" slash "^3.0.0" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -4383,6 +4433,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +follow-redirects@^1.14.8: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -4390,6 +4445,15 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + fraction.js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" @@ -6098,6 +6162,18 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.2" picomatch "^2.3.1" +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + mime@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7" @@ -6753,6 +6829,14 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +openai@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-3.1.0.tgz#13bfb228cf777155b882c2deb3a03bc5094cb7b3" + integrity sha512-v5kKFH5o+8ld+t0arudj833Mgm3GcgBnbyN9946bj6u7bvel4Yg6YFz2A4HLIYDzmMjIo0s6vSG9x73kOwvdCg== + dependencies: + axios "^0.26.0" + form-data "^4.0.0" + opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -7866,11 +7950,6 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-similarity@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" - integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== - "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -8418,6 +8497,16 @@ validate-npm-package-name@^4.0.0: dependencies: builtins "^5.0.0" +validate.io-array@^1.0.3, validate.io-array@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/validate.io-array/-/validate.io-array-1.0.6.tgz#5b5a2cafd8f8b85abb2f886ba153f2d93a27774d" + integrity sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg== + +validate.io-function@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/validate.io-function/-/validate.io-function-1.0.2.tgz#343a19802ed3b1968269c780e558e93411c0bad7" + integrity sha512-LlFybRJEriSuBnUhQyG5bwglhh50EpTL2ul23MPIuR1odjO7XaMLFV8vHGwp7AZciFxtYOeiSCT5st+XSPONiQ== + walk-up-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/walk-up-path/-/walk-up-path-1.0.0.tgz#d4745e893dd5fd0dbb58dd0a4c6a33d9c9fec53e" From a0ffec0bbbc26003f316aaf0204a1159830bb083 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 15:43:09 -0300 Subject: [PATCH 3/9] feat: :sparkles: adds ai based flashcards --- src/server/api/root.ts | 2 + src/server/api/routers/cards/cards.router.ts | 21 ++++++++ src/server/api/routers/cards/index.ts | 1 + src/utils/openai/index.ts | 56 ++++++++++++++++++-- src/utils/string/index.ts | 2 + 5 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/server/api/routers/cards/cards.router.ts create mode 100644 src/server/api/routers/cards/index.ts create mode 100644 src/utils/string/index.ts diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 76a4f49..a3b3a85 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,3 +1,4 @@ +import { cardsRouter } from './routers/cards' import { decksRouter } from './routers/decks' import { filesRouter } from './routers/files' import { studySessionRouter } from './routers/study-session' @@ -14,6 +15,7 @@ export const appRouter = createTRPCRouter({ files: filesRouter, studySession: studySessionRouter, user: userRouter, + cards: cardsRouter, }) export type AppRouter = typeof appRouter diff --git a/src/server/api/routers/cards/cards.router.ts b/src/server/api/routers/cards/cards.router.ts new file mode 100644 index 0000000..76440d4 --- /dev/null +++ b/src/server/api/routers/cards/cards.router.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +import { MAX_TOPICS_PER_DECK_AND_USER } from '~/constants' +import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc' +import { generateFlashCards } from '~/utils/openai' +import { TopicInputSchema } from '~/utils/validators/topic' + +export const cardsRouter = createTRPCRouter({ + generateCards: protectedProcedure + .input( + z.object({ + topics: z + .array(TopicInputSchema) + .min(1) + .max(MAX_TOPICS_PER_DECK_AND_USER), + }), + ) + .mutation(({ input: { topics } }) => { + return generateFlashCards({ topics }) + }), +}) diff --git a/src/server/api/routers/cards/index.ts b/src/server/api/routers/cards/index.ts new file mode 100644 index 0000000..384ef40 --- /dev/null +++ b/src/server/api/routers/cards/index.ts @@ -0,0 +1 @@ +export { cardsRouter } from './cards.router' diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts index 901a3c1..a6c4661 100644 --- a/src/utils/openai/index.ts +++ b/src/utils/openai/index.ts @@ -1,9 +1,13 @@ +/** + * !DO NO USE THIS FILE IN THE CLIENT SIDE + */ import calculateSimilarity from 'compute-cosine-similarity' import { Configuration, OpenAIApi } from 'openai' +import type { TopicInput } from '~/contexts/create-new-deck/create-new-deck.types' import { env } from '~/env/server.mjs' -import { isServerSide } from '../runtime' +import { sanitize } from '../string' const configuration = new Configuration({ apiKey: env.OPENAI_API_KEY, @@ -22,10 +26,6 @@ const createEmbedding = (str: string) => * Reference: https://platform.openai.com/docs/guides/embeddings/limitations-risks */ export async function verifyStringsSimilarity(str1: string, str2: string) { - if (!isServerSide()) { - throw new Error('Function can only be called server-side.') - } - const [firstStringResponse, secondStringResponse] = await Promise.all([ createEmbedding(str1), createEmbedding(str2), @@ -42,3 +42,49 @@ export async function verifyStringsSimilarity(str1: string, str2: string) { return similarity } + +type GenerateFlashCardsParam = { + topics: Array +} + +export async function generateFlashCards({ topics }: GenerateFlashCardsParam) { + /** Created to possibly use as params in the future */ + const amountOfCards = 3 + const tokensPerCard = 40 + + /** Build topics strings */ + const joinedTopics = topics.map(({ title }) => title).join(', ') + + /** Build prompt asking OpenAI to generate a csv string */ + const prompt = `Gere um csv com ${amountOfCards} perguntas e respostas curtas sobre: ${joinedTopics}.` + + const response = await openai.createCompletion({ + n: 1, + prompt, + temperature: 0.8, + model: 'text-davinci-003', + max_tokens: amountOfCards * tokensPerCard, + }) + + const generatedText = response.data.choices[0]?.text + + if (!generatedText) { + throw new Error('Could not generate questions and answers') + } + + /** Get CSV lines and remove first line which is the CSV header */ + const separator = ',' + const lines = generatedText.split('\n').filter(Boolean) + const questionsAndAnswers = lines.slice(1, lines.length) + + const cards = questionsAndAnswers.map(content => { + const [question = '', answer = ''] = content.split(separator) + + return { + question: sanitize(question), + answer: sanitize(answer), + } + }) + + return cards +} diff --git a/src/utils/string/index.ts b/src/utils/string/index.ts new file mode 100644 index 0000000..c38a066 --- /dev/null +++ b/src/utils/string/index.ts @@ -0,0 +1,2 @@ +export const sanitize = (str: string) => + str.replace(/[^\w ,.áéíñóúü-]/gim, '').trim() From 205d5b1a8b0abbd083a8b1b63bcbebaa4d3f2bd9 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 17:22:28 -0300 Subject: [PATCH 4/9] feat: :sparkles: adds AI powered cards --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/components/card/card.component.tsx | 23 +++++++++-- src/components/card/card.types.ts | 1 + src/components/tooltip/tooltip.component.tsx | 6 ++- .../create-new-deck.context.tsx | 41 +++++++++++++++++++ .../create-new-deck/create-new-deck.types.ts | 10 +++++ .../create/components/cards.component.tsx | 40 +++++++++++++++++- src/pages/decks/[id].tsx | 4 +- src/server/api/routers/cards/cards.router.ts | 2 +- src/utils/openai/index.ts | 20 +++++---- src/utils/string/index.ts | 2 - src/utils/validators/card.ts | 1 + 13 files changed, 135 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/20230305201901_adds_is_ai_powered_flag_to_card/migration.sql delete mode 100644 src/utils/string/index.ts diff --git a/prisma/migrations/20230305201901_adds_is_ai_powered_flag_to_card/migration.sql b/prisma/migrations/20230305201901_adds_is_ai_powered_flag_to_card/migration.sql new file mode 100644 index 0000000..9709a0a --- /dev/null +++ b/prisma/migrations/20230305201901_adds_is_ai_powered_flag_to_card/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Card" ADD COLUMN "isAiPowered" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bdeb88c..da1da52 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,6 +96,7 @@ model Card { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt studySessionBoxCards StudySessionBoxCard[] + isAiPowered Boolean @default(false) } model StudySession { diff --git a/src/components/card/card.component.tsx b/src/components/card/card.component.tsx index 65f8378..5487271 100644 --- a/src/components/card/card.component.tsx +++ b/src/components/card/card.component.tsx @@ -4,17 +4,19 @@ import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline' import { classNames } from '~/utils/css' +import { Tooltip } from '../tooltip' import type { CardProps } from './card.types' export function _Card(props: CardProps) { const { + as, + onClick, children, + fullWidth, isEditable, - onDeletePress, + isAiPowered, onEditPress, - onClick, - as, - fullWidth, + onDeletePress, } = props const renderEditButtons = () => { @@ -32,6 +34,18 @@ export function _Card(props: CardProps) { ) } + const renderAiPoweredTag = () => { + if (!isAiPowered) return null + + return ( + + + 🤖 + + + ) + } + const Container = as || 'div' return ( @@ -45,6 +59,7 @@ export function _Card(props: CardProps) { > {children} {renderEditButtons()} + {renderAiPoweredTag()} ) } diff --git a/src/components/card/card.types.ts b/src/components/card/card.types.ts index 69bf9dc..eed2e22 100644 --- a/src/components/card/card.types.ts +++ b/src/components/card/card.types.ts @@ -8,4 +8,5 @@ export type CardProps = { onClick?: () => void as?: ReactTag fullWidth?: boolean + isAiPowered?: boolean } diff --git a/src/components/tooltip/tooltip.component.tsx b/src/components/tooltip/tooltip.component.tsx index 79f9177..fb25b87 100644 --- a/src/components/tooltip/tooltip.component.tsx +++ b/src/components/tooltip/tooltip.component.tsx @@ -32,7 +32,11 @@ function _Tooltip(props: TooltipProps) { return ( <> {renderTooltipTrigger()} - +

    {hint}

    diff --git a/src/contexts/create-new-deck/create-new-deck.context.tsx b/src/contexts/create-new-deck/create-new-deck.context.tsx index 22afff5..e2abaee 100644 --- a/src/contexts/create-new-deck/create-new-deck.context.tsx +++ b/src/contexts/create-new-deck/create-new-deck.context.tsx @@ -19,6 +19,7 @@ import type { CreateNewDeckContextState, DeckWithCardsAndTopics, FormInputValues, + GenerateAiPoweredCardsParams, TopicInput, } from './create-new-deck.types' import { DeckInputFormSchema } from './create-new-deck.types' @@ -73,6 +74,20 @@ export function CreateNewDeckContextProvider( const getFileUploadConfigMutation = api.files.getFileUploadConfig.useMutation() + const { + mutate: generateAiPoweredCardsMutation, + isLoading: isGeneratingAiPoweredCards, + isError: hasErrorGeneratingAiPoweredCards, + } = api.cards.generateAiPoweredCards.useMutation({ + onSuccess: aiPoweredCards => { + setCards(prevCards => [...prevCards, ...aiPoweredCards]) + notify.success('Bip Bop, cards gerados com sucesso. Aproveite 🤖') + }, + onError: () => { + notify.error('Ocorreu um erro ao gerar os cards. Tente novamente!') + }, + }) + /** * Shared states between creation and edit */ @@ -93,6 +108,8 @@ export function CreateNewDeckContextProvider( const [deletedCards, setDeletedCards] = useState>([]) const [editedCards, setEditedCards] = useState>([]) + const hasDeckAiPoweredCards = cards.some(card => card.isAiPowered) + const createNewDeckForm = useForm({ resolver: zodResolver(DeckInputFormSchema), defaultValues: { @@ -246,6 +263,26 @@ export function CreateNewDeckContextProvider( } } + const generateAiPoweredCards = (params: GenerateAiPoweredCardsParams) => { + const { topics } = params + + if (isGeneratingAiPoweredCards) return + + if (topics.length === 0) { + notify.warning( + 'Você precisa cadastrar ao menos 1 tópico para gerar Cards de forma automática', + ) + return + } + + if (hasDeckAiPoweredCards) { + notify.error('Este Deck já possui Cards gerados por uma IA') + return + } + + generateAiPoweredCardsMutation({ topics }) + } + /** * If isEditingDeck uses update function (currying) else uses the creation function */ @@ -270,6 +307,10 @@ export function CreateNewDeckContextProvider( visibility, setVisibility, + + generateAiPoweredCards, + isGeneratingAiPoweredCards, + hasErrorGeneratingAiPoweredCards, } return ( diff --git a/src/contexts/create-new-deck/create-new-deck.types.ts b/src/contexts/create-new-deck/create-new-deck.types.ts index fecf80a..b222b05 100644 --- a/src/contexts/create-new-deck/create-new-deck.types.ts +++ b/src/contexts/create-new-deck/create-new-deck.types.ts @@ -39,6 +39,8 @@ export type CreateNewDeckContextProviderProps = { deck?: DeckWithCardsAndTopics | null } +export type GenerateAiPoweredCardsParams = { topics: Array } + export type CreateNewDeckContextState = { createNewDeckForm?: UseFormReturn submitDeck: (values: FormInputValues) => Promise @@ -55,6 +57,10 @@ export type CreateNewDeckContextState = { visibilityOptions: Array> visibility?: Option setVisibility: Dispatch | undefined>> + + generateAiPoweredCards: (params: GenerateAiPoweredCardsParams) => void + isGeneratingAiPoweredCards: boolean + hasErrorGeneratingAiPoweredCards: boolean } export const initialState: CreateNewDeckContextState = { @@ -72,4 +78,8 @@ export const initialState: CreateNewDeckContextState = { visibilityOptions: DECK_VISIBILITY_OPTIONS, visibility: DECK_VISIBILITY_OPTIONS[0], setVisibility: noop, + + generateAiPoweredCards: noop, + isGeneratingAiPoweredCards: false, + hasErrorGeneratingAiPoweredCards: false, } diff --git a/src/modules/decks/create/components/cards.component.tsx b/src/modules/decks/create/components/cards.component.tsx index 213f872..739dbf4 100644 --- a/src/modules/decks/create/components/cards.component.tsx +++ b/src/modules/decks/create/components/cards.component.tsx @@ -3,8 +3,10 @@ import { useState } from 'react' import { PlusCircleIcon } from '@heroicons/react/24/outline' import { Card } from '~/components/card' +import { Loader } from '~/components/loader' import { NewCardModal } from '~/components/modal/new-card/new-card-modal.component' import type { CardFormInputValues } from '~/components/modal/new-card/new-card-modal.types' +import { Tooltip } from '~/components/tooltip' import { useCreateNewDeckContext } from '~/contexts/create-new-deck' type NewCardModalState = { @@ -13,7 +15,16 @@ type NewCardModalState = { } export const Cards = () => { - const { cards, addCard, deleteCard, editCard } = useCreateNewDeckContext() + const { + cards, + addCard, + deleteCard, + editCard, + topics, + generateAiPoweredCards, + isGeneratingAiPoweredCards, + hasErrorGeneratingAiPoweredCards, + } = useCreateNewDeckContext() const [newCardModalState, setNewCardModalState] = useState( { isOpen: false }, @@ -40,6 +51,31 @@ export const Cards = () => { })) } + const renderAiCardsButton = () => { + const successContent = isGeneratingAiPoweredCards ? ( + + ) : ( + 🤖 + ) + + const errorContent = ( + + Houve um erro ao gerar os Cards. Clique aqui para tentar novamente! + + ) + + return ( + generateAiPoweredCards({ topics })}> + {hasErrorGeneratingAiPoweredCards ? errorContent : successContent} +
    + +
    +
    + ) + } + return ( <>

    Cards

    @@ -47,6 +83,7 @@ export const Cards = () => { {cards.map((card, idx) => ( deleteCard(idx)} onEditPress={() => { @@ -59,6 +96,7 @@ export const Cards = () => { setNewCardModalState({ isOpen: true })}> + {renderAiCardsButton()} Cards:
      {deck.cards.map(card => ( - + {card.question} ))} diff --git a/src/server/api/routers/cards/cards.router.ts b/src/server/api/routers/cards/cards.router.ts index 76440d4..d9cbf78 100644 --- a/src/server/api/routers/cards/cards.router.ts +++ b/src/server/api/routers/cards/cards.router.ts @@ -6,7 +6,7 @@ import { generateFlashCards } from '~/utils/openai' import { TopicInputSchema } from '~/utils/validators/topic' export const cardsRouter = createTRPCRouter({ - generateCards: protectedProcedure + generateAiPoweredCards: protectedProcedure .input( z.object({ topics: z diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts index a6c4661..901e97f 100644 --- a/src/utils/openai/index.ts +++ b/src/utils/openai/index.ts @@ -4,11 +4,12 @@ import calculateSimilarity from 'compute-cosine-similarity' import { Configuration, OpenAIApi } from 'openai' -import type { TopicInput } from '~/contexts/create-new-deck/create-new-deck.types' +import type { + CardInput, + TopicInput, +} from '~/contexts/create-new-deck/create-new-deck.types' import { env } from '~/env/server.mjs' -import { sanitize } from '../string' - const configuration = new Configuration({ apiKey: env.OPENAI_API_KEY, }) @@ -21,6 +22,8 @@ const createEmbedding = (str: string) => input: str.replace('\n', ' '), }) +const trimAndRemoveDoubleQuotes = (str: string) => str.trim().replace('"', '') + /** * 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 @@ -47,7 +50,9 @@ type GenerateFlashCardsParam = { topics: Array } -export async function generateFlashCards({ topics }: GenerateFlashCardsParam) { +export async function generateFlashCards({ + topics, +}: GenerateFlashCardsParam): Promise> { /** Created to possibly use as params in the future */ const amountOfCards = 3 const tokensPerCard = 40 @@ -77,12 +82,13 @@ export async function generateFlashCards({ topics }: GenerateFlashCardsParam) { const lines = generatedText.split('\n').filter(Boolean) const questionsAndAnswers = lines.slice(1, lines.length) - const cards = questionsAndAnswers.map(content => { + const cards: Array = questionsAndAnswers.map(content => { const [question = '', answer = ''] = content.split(separator) return { - question: sanitize(question), - answer: sanitize(answer), + question: trimAndRemoveDoubleQuotes(question), + answer: trimAndRemoveDoubleQuotes(answer), + isAiPowered: true, } }) diff --git a/src/utils/string/index.ts b/src/utils/string/index.ts deleted file mode 100644 index c38a066..0000000 --- a/src/utils/string/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const sanitize = (str: string) => - str.replace(/[^\w ,.áéíñóúü-]/gim, '').trim() diff --git a/src/utils/validators/card.ts b/src/utils/validators/card.ts index cf50221..313fd56 100644 --- a/src/utils/validators/card.ts +++ b/src/utils/validators/card.ts @@ -2,6 +2,7 @@ import { z } from 'zod' export const CardInputSchema = z.object({ id: z.string().optional(), + isAiPowered: z.boolean().optional(), question: z .string({ required_error: 'A pergunta de um Card é obrigatória', From ffc38886eebc38ad48402fdd8b59802216ee8378 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 17:37:55 -0300 Subject: [PATCH 5/9] fix: :bug: remove double quotes --- src/utils/openai/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts index 901e97f..c598351 100644 --- a/src/utils/openai/index.ts +++ b/src/utils/openai/index.ts @@ -22,7 +22,8 @@ const createEmbedding = (str: string) => input: str.replace('\n', ' '), }) -const trimAndRemoveDoubleQuotes = (str: string) => str.trim().replace('"', '') +const trimAndRemoveDoubleQuotes = (str: string) => + str.trim().replace(/^"(.+(?="$))"$/, '$1') /** * Embed both strings with text-embedding-ada-002 and calculate their distance with cosine similarity From 0f041bae75c6e5bc3db8410377c9b6646fd491a8 Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 17:51:18 -0300 Subject: [PATCH 6/9] refactor: :recycle: improve generateFlashCards function --- src/utils/openai/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts index c598351..480efa7 100644 --- a/src/utils/openai/index.ts +++ b/src/utils/openai/index.ts @@ -62,7 +62,7 @@ export async function generateFlashCards({ const joinedTopics = topics.map(({ title }) => title).join(', ') /** Build prompt asking OpenAI to generate a csv string */ - const prompt = `Gere um csv com ${amountOfCards} perguntas e respostas curtas sobre: ${joinedTopics}.` + const prompt = `Gere um csv com ${amountOfCards} perguntas e respostas curtas sobre: ${joinedTopics}. Siga a seguinte estrutura CSV: pergunta;resposta` const response = await openai.createCompletion({ n: 1, @@ -79,9 +79,8 @@ export async function generateFlashCards({ } /** Get CSV lines and remove first line which is the CSV header */ - const separator = ',' - const lines = generatedText.split('\n').filter(Boolean) - const questionsAndAnswers = lines.slice(1, lines.length) + const separator = ';' + const questionsAndAnswers = generatedText.split('\n').filter(Boolean) const cards: Array = questionsAndAnswers.map(content => { const [question = '', answer = ''] = content.split(separator) From 3361fe1a49248aa5b02d78e716e2919d45d7c10c Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 17:59:08 -0300 Subject: [PATCH 7/9] refactor: :recycle: increase the amount of tokens per card --- src/utils/openai/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts index 480efa7..97177da 100644 --- a/src/utils/openai/index.ts +++ b/src/utils/openai/index.ts @@ -56,7 +56,7 @@ export async function generateFlashCards({ }: GenerateFlashCardsParam): Promise> { /** Created to possibly use as params in the future */ const amountOfCards = 3 - const tokensPerCard = 40 + const tokensPerCard = 50 /** Build topics strings */ const joinedTopics = topics.map(({ title }) => title).join(', ') From 9083cf855613c656b0d6ef08ab95de197f60038e Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 18:02:28 -0300 Subject: [PATCH 8/9] refactor: :recycle: decrease the amount of tokens per card --- src/utils/openai/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/openai/index.ts b/src/utils/openai/index.ts index 97177da..480efa7 100644 --- a/src/utils/openai/index.ts +++ b/src/utils/openai/index.ts @@ -56,7 +56,7 @@ export async function generateFlashCards({ }: GenerateFlashCardsParam): Promise> { /** Created to possibly use as params in the future */ const amountOfCards = 3 - const tokensPerCard = 50 + const tokensPerCard = 40 /** Build topics strings */ const joinedTopics = topics.map(({ title }) => title).join(', ') From c97de49602a9554a1a5cf2caaaea1e4b2dcd741a Mon Sep 17 00:00:00 2001 From: Emilio Schaedler Heinzmann Date: Sun, 5 Mar 2023 18:04:25 -0300 Subject: [PATCH 9/9] refactor: :recycle: change tooltip text for ai powered cards --- src/modules/decks/create/components/cards.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/decks/create/components/cards.component.tsx b/src/modules/decks/create/components/cards.component.tsx index 739dbf4..df9c7ee 100644 --- a/src/modules/decks/create/components/cards.component.tsx +++ b/src/modules/decks/create/components/cards.component.tsx @@ -69,7 +69,7 @@ export const Cards = () => { {hasErrorGeneratingAiPoweredCards ? errorContent : successContent}