From 72ed2c4d769895118529fcee5adb2771f3d98244 Mon Sep 17 00:00:00 2001 From: Rajkumar Date: Sun, 27 Sep 2026 00:37:01 +0530 Subject: [PATCH] optimization(bugs) genAi docker container local llm and huge dependencies reduced --- ARCHITECTURE.md | 12 +- apps/frontend/package.json | 10 +- .../src/components/AiQuizGenerator.test.tsx | 305 ++++++++ .../src/components/AiQuizGenerator.tsx | 391 ++-------- .../frontend/src/components/ai/AiProgress.tsx | 47 ++ apps/frontend/src/components/ai/AskPdfTab.tsx | 110 +++ .../src/components/ai/AssistantTab.tsx | 127 ++++ .../src/components/ai/GenerateQuizTab.tsx | 505 +++++++++++++ .../src/components/ai/PdfDropzone.tsx | 120 +++ apps/frontend/src/components/ai/difficulty.ts | 7 + .../src/components/ai/useAiRequest.ts | 54 ++ .../frontend/src/screens/QuizBuilder.test.tsx | 123 +++ apps/frontend/src/screens/QuizBuilder.tsx | 33 +- apps/frontend/src/services/genaiApi.test.ts | 76 ++ apps/frontend/src/services/genaiApi.ts | 75 +- apps/frontend/src/test/setup.ts | 5 + apps/frontend/vite.config.ts | 5 + apps/genAI/Dockerfile | 10 +- apps/genAI/Dockerfile.prod | 30 +- apps/genAI/README.md | 22 +- apps/genAI/app/agent/Qnagent.py | 37 +- apps/genAI/app/main.py | 167 +++-- apps/genAI/app/prompts/quiz_json_prompt.py | 9 +- apps/genAI/app/rag/rag_system.py | 142 +++- apps/genAI/package.json | 5 +- apps/genAI/pyproject.toml | 3 + apps/genAI/requirements-dev.txt | 5 + apps/genAI/requirements.txt | 11 +- apps/genAI/tests/__init__.py | 0 apps/genAI/tests/conftest.py | 141 ++++ apps/genAI/tests/test_ask_and_chat.py | 66 ++ apps/genAI/tests/test_generate_quiz.py | 152 ++++ .../src/controllers/quizController.ts | 7 +- apps/http-server/src/routes/quiz.ts | 4 +- docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- .../migration.sql | 5 + packages/db/prisma/schema.prisma | 11 +- planning.md | 2 +- pnpm-lock.yaml | 702 ++++++++++++++++++ 40 files changed, 3025 insertions(+), 515 deletions(-) create mode 100644 apps/frontend/src/components/AiQuizGenerator.test.tsx create mode 100644 apps/frontend/src/components/ai/AiProgress.tsx create mode 100644 apps/frontend/src/components/ai/AskPdfTab.tsx create mode 100644 apps/frontend/src/components/ai/AssistantTab.tsx create mode 100644 apps/frontend/src/components/ai/GenerateQuizTab.tsx create mode 100644 apps/frontend/src/components/ai/PdfDropzone.tsx create mode 100644 apps/frontend/src/components/ai/difficulty.ts create mode 100644 apps/frontend/src/components/ai/useAiRequest.ts create mode 100644 apps/frontend/src/screens/QuizBuilder.test.tsx create mode 100644 apps/frontend/src/services/genaiApi.test.ts create mode 100644 apps/frontend/src/test/setup.ts create mode 100644 apps/genAI/requirements-dev.txt create mode 100644 apps/genAI/tests/__init__.py create mode 100644 apps/genAI/tests/conftest.py create mode 100644 apps/genAI/tests/test_ask_and_chat.py create mode 100644 apps/genAI/tests/test_generate_quiz.py create mode 100644 packages/db/prisma/migrations/20260927090000_question_difficulty/migration.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b0fc60..4b42029 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -209,8 +209,8 @@ score nothing. ### genAI — AI question generation -FastAPI, isolated from the Node services so the ML dependency tree (torch, -sentence-transformers, faiss) never touches them. +FastAPI, isolated from the Node services so the Python AI dependency tree +(LangChain, ONNX Runtime) never touches them. | Endpoint | Purpose | |---|---| @@ -221,7 +221,8 @@ sentence-transformers, faiss) never touches them. | `GET /health` | Liveness, used by the container healthcheck | Providers: **Groq** for question generation, **Gemini** for chat, **Tavily** -for web search, **HuggingFace** sentence-transformers for embeddings. +for web search, **fastembed** (`bge-small-en-v1.5` on ONNX Runtime, CPU only) +for embeddings. All clients are built **lazily on first use**. Constructing them at import time meant a missing API key crashed the whole service on startup — including @@ -502,8 +503,8 @@ are long-lived and stateful. Splitting lets you run many WS instances during a big quiz without over-provisioning the REST tier. **Why is the AI service Python and separate?** -The libraries that matter (torch, sentence-transformers, faiss) are Python-only. -Isolating them keeps a multi-GB dependency tree out of the Node images, lets the +The libraries that matter (LangChain, fastembed) are Python-only. +Isolating them keeps the ML dependency tree out of the Node images, lets the service scale independently, and means an LLM outage degrades one feature rather than taking down the platform. @@ -531,7 +532,6 @@ Honest list of what is not solved yet. | **Join codes** | Generation has a race condition — two concurrent requests can collide (tracked in [issues.md](issues.md) #1) | | **AI vector store** | `InMemoryVectorStore` is rebuilt per request; no caching, so the same PDF is re-embedded every time (#5) | | **AI cost** | No rate limiting or per-user quota on generation endpoints | -| **Uploads** | PDFs are written to a volume and never cleaned up | | **Reconnection** | A participant who drops mid-quiz rejoins the lobby, but in-flight question state is not restored | | **Deploys** | `docker compose up -d` recreates containers — brief downtime, no rolling update | | **Environments** | No staging; `main` deploys straight to production | diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 22c79fe..5a633c7 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -8,7 +8,8 @@ "start": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@repo/db": "workspace:*", @@ -28,6 +29,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -36,8 +40,10 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "jsdom": "^30.1.1", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^5.0.2" } } diff --git a/apps/frontend/src/components/AiQuizGenerator.test.tsx b/apps/frontend/src/components/AiQuizGenerator.test.tsx new file mode 100644 index 0000000..7f505c0 --- /dev/null +++ b/apps/frontend/src/components/AiQuizGenerator.test.tsx @@ -0,0 +1,305 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CanceledError } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GeneratedQuestion, RequestOptions } from '../services/genaiApi'; +import AiQuizGenerator from './AiQuizGenerator'; + +vi.mock('../services/genaiApi', async (importOriginal) => ({ + ...(await importOriginal()), + generateQuiz: vi.fn(), + askPdf: vi.fn(), + chat: vi.fn(), +})); + +const api = await import('../services/genaiApi'); +const generateQuiz = vi.mocked(api.generateQuiz); +const askPdf = vi.mocked(api.askPdf); +const chat = vi.mocked(api.chat); + +const question = (text: string, difficulty: GeneratedQuestion['difficulty'] = 'Medium'): GeneratedQuestion => ({ + text, + difficulty, + options: [0, 1, 2, 3].map((i) => ({ text: `${text} option ${i}`, isCorrect: i === 0 })), +}); + +const pdf = () => new File(['%PDF-1.4'], 'notes.pdf', { type: 'application/pdf' }); + +function setup(props: Partial> = {}) { + const handlers = { + onSave: vi + .fn<(question: GeneratedQuestion, timeLimit: number) => Promise>() + .mockResolvedValue(undefined), + onSaved: vi.fn(), + onClose: vi.fn(), + }; + + render(); + + return { user: userEvent.setup(), ...handlers }; +} + +async function choosePdf(user: ReturnType) { + await user.upload(screen.getByTestId('pdf-input'), pdf()); +} + +async function generate(user: ReturnType, questions: GeneratedQuestion[]) { + generateQuiz.mockResolvedValueOnce(questions); + await user.click(screen.getByRole('button', { name: /^generate \d+ question/i })); + await waitFor(() => expect(screen.getAllByTestId('draft')).toHaveLength(questions.length)); +} + +const draftTexts = () => + screen.getAllByLabelText('Question text').map((el) => (el as HTMLInputElement).value); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('PDF picker', () => { + it('keeps Generate disabled until a PDF is chosen', async () => { + const { user } = setup(); + const button = screen.getByRole('button', { name: /generate 5 questions/i }); + + expect(button).toBeDisabled(); + await choosePdf(user); + expect(button).toBeEnabled(); + expect(screen.getByText('notes.pdf')).toBeInTheDocument(); + }); + + it('rejects files that are not PDFs', () => { + setup(); + + fireEvent.change(screen.getByTestId('pdf-input'), { + target: { files: [new File(['x'], 'notes.docx', { type: 'application/msword' })] }, + }); + + expect(screen.getByRole('alert')).toHaveTextContent('Only PDF files are supported.'); + expect(screen.queryByText('notes.docx')).not.toBeInTheDocument(); + }); + + it('rejects PDFs over the size limit before uploading', () => { + setup(); + const big = pdf(); + Object.defineProperty(big, 'size', { value: 11 * 1024 * 1024 }); + + fireEvent.change(screen.getByTestId('pdf-input'), { target: { files: [big] } }); + + expect(screen.getByRole('alert')).toHaveTextContent('The limit is 10 MB.'); + expect(screen.getByRole('button', { name: /generate 5 questions/i })).toBeDisabled(); + }); + + it('accepts a dropped PDF and can remove it', async () => { + const { user } = setup(); + + fireEvent.drop(screen.getByText(/drop a pdf here/i), { + dataTransfer: { files: [pdf()] }, + }); + + expect(screen.getByText('notes.pdf')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Remove PDF' })); + expect(screen.queryByText('notes.pdf')).not.toBeInTheDocument(); + }); +}); + +describe('Generating', () => { + it('sends the chosen settings and excludes questions already in the quiz', async () => { + const { user } = setup(); + await choosePdf(user); + + await user.click(screen.getByRole('button', { name: '10' })); + await user.click(screen.getByRole('button', { name: 'High' })); + await user.type(screen.getByLabelText('Focus'), 'chapter 2'); + await generate(user, [question('Q1', 'High')]); + + expect(generateQuiz).toHaveBeenCalledWith( + expect.any(File), + { count: 10, difficulty: 'High', topic: 'chapter 2', exclude: ['Already in quiz?'] }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it('appends a second batch instead of replacing unsaved drafts', async () => { + const { user } = setup(); + await choosePdf(user); + + await generate(user, [question('Q1'), question('Q2')]); + generateQuiz.mockResolvedValueOnce([question('Q3')]); + await user.click(screen.getByRole('button', { name: /generate 5 questions/i })); + + await waitFor(() => expect(draftTexts()).toEqual(['Q1', 'Q2', 'Q3'])); + expect(generateQuiz.mock.calls[1][1].exclude).toEqual(['Already in quiz?', 'Q1', 'Q2']); + }); + + it('shows the API error message', async () => { + const { user } = setup(); + await choosePdf(user); + + generateQuiz.mockRejectedValueOnce(new Error('boom')); + await user.click(screen.getByRole('button', { name: /generate 5 questions/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Failed to generate questions.'); + }); + + it('shows progress and can be cancelled without an error', async () => { + const { user } = setup(); + await choosePdf(user); + + generateQuiz.mockImplementationOnce( + (_file, _settings, options?: RequestOptions) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new CanceledError())); + }) + ); + + await user.click(screen.getByRole('button', { name: /generate 5 questions/i })); + + const progress = await screen.findByRole('status'); + expect(progress).toHaveTextContent(/reading the pdf and writing questions/i); + + await user.click(within(progress).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /generate 5 questions/i })).toBeEnabled(); + }); +}); + +describe('Reviewing', () => { + it('regenerates one question in place with its difficulty', async () => { + const { user } = setup(); + await choosePdf(user); + await generate(user, [question('Q1', 'Low'), question('Q2', 'High'), question('Q3')]); + + generateQuiz.mockResolvedValueOnce([question('Fresh Q2', 'High')]); + await user.click(screen.getAllByRole('button', { name: 'Regenerate this question' })[1]); + + await waitFor(() => expect(draftTexts()).toEqual(['Q1', 'Fresh Q2', 'Q3'])); + expect(generateQuiz.mock.calls[1][1]).toEqual({ + count: 1, + difficulty: 'High', + topic: '', + exclude: ['Already in quiz?', 'Q1', 'Q2', 'Q3'], + }); + }); + + it('selects all and none', async () => { + const { user } = setup(); + await choosePdf(user); + await generate(user, [question('Q1'), question('Q2')]); + + await user.click(screen.getByRole('button', { name: 'Select none' })); + expect(screen.getByRole('button', { name: 'Add 0 to Quiz' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Select all' })); + expect(screen.getByRole('button', { name: 'Add 2 to Quiz' })).toBeEnabled(); + }); + + it('rejects an invalid time limit', async () => { + const { user, onSave } = setup(); + await choosePdf(user); + await generate(user, [question('Q1')]); + + const seconds = screen.getByRole('spinbutton', { name: 'Seconds per question' }); + await user.clear(seconds); + await user.type(seconds, '2'); + await user.click(screen.getByRole('button', { name: 'Add 1 to Quiz' })); + + expect(screen.getByRole('alert')).toHaveTextContent('from 5 to 300 seconds'); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('saves edits, the chosen difficulty and the time preset, then closes', async () => { + const { user, onSave, onSaved, onClose } = setup(); + await choosePdf(user); + await generate(user, [question('Q1'), question('Q2')]); + + await user.selectOptions(screen.getAllByLabelText('Question difficulty')[0], 'High'); + await user.click(screen.getAllByRole('button', { name: 'Mark option 3 correct' })[0]); + await user.click(screen.getByRole('button', { name: '30s' })); + await user.click(screen.getByRole('button', { name: 'Add 2 to Quiz' })); + + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledTimes(2); + + const [saved, seconds] = onSave.mock.calls[0]; + expect(seconds).toBe(30); + expect(saved.difficulty).toBe('High'); + expect(saved.options.map((o) => o.isCorrect)).toEqual([false, false, true, false]); + expect(onSaved).toHaveBeenCalledTimes(1); + expect(screen.queryAllByTestId('draft')).toHaveLength(0); + }); + + it('never saves a question twice after a partial failure', async () => { + const { user, onSave, onSaved, onClose } = setup(); + await choosePdf(user); + await generate(user, [question('Q1'), question('Q2'), question('Q3')]); + + onSave.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('network')); + await user.click(screen.getByRole('button', { name: 'Add 3 to Quiz' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Saved 1 of 3 questions'); + expect(draftTexts()).toEqual(['Q2', 'Q3']); + // the question that did save shows up in the quiz straight away + expect(onSaved).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Add 2 to Quiz' })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + + const savedTexts = onSave.mock.calls.map(([q]) => q.text); + expect(savedTexts).toEqual(['Q1', 'Q2', 'Q2', 'Q3']); + expect(savedTexts.filter((t) => t === 'Q1')).toHaveLength(1); + }); +}); + +describe('Other tabs', () => { + it('answers questions about the same PDF', async () => { + const { user } = setup(); + await choosePdf(user); + + await user.click(screen.getByRole('tab', { name: 'Ask the PDF' })); + expect(screen.getByText('notes.pdf')).toBeInTheDocument(); + + askPdf.mockResolvedValueOnce({ + message: 'ok', + filename: 'notes.pdf', + question: 'What is covered?', + answer: 'Photosynthesis.', + }); + await user.type(screen.getByLabelText('Question about the PDF'), 'What is covered?{Enter}'); + + const exchange = await screen.findByTestId('exchange'); + expect(exchange).toHaveTextContent('What is covered?'); + expect(exchange).toHaveTextContent('Photosynthesis.'); + expect(askPdf).toHaveBeenCalledWith(expect.any(File), 'What is covered?', expect.anything()); + }); + + it('chats with the assistant and keeps drafts when switching tabs', async () => { + const { user } = setup(); + await choosePdf(user); + await generate(user, [question('Q1')]); + + await user.click(screen.getByRole('tab', { name: 'Assistant' })); + chat.mockResolvedValueOnce('Here are some facts.'); + await user.type(screen.getByLabelText('Message the assistant'), 'Solar system facts{Enter}'); + + expect(await screen.findByTestId('message-assistant')).toHaveTextContent('Here are some facts.'); + expect(screen.getByTestId('message-user')).toHaveTextContent('Solar system facts'); + expect(screen.getByLabelText('Message the assistant')).toHaveValue(''); + + await user.click(screen.getByRole('tab', { name: 'Generate quiz' })); + expect(draftTexts()).toEqual(['Q1']); + }); + + it('shows assistant errors', async () => { + const { user } = setup(); + await user.click(screen.getByRole('tab', { name: 'Assistant' })); + + chat.mockRejectedValueOnce(new Error('down')); + await user.type(screen.getByLabelText('Message the assistant'), 'hi{Enter}'); + + expect(await screen.findByRole('alert')).toHaveTextContent('The assistant failed to reply.'); + }); +}); diff --git a/apps/frontend/src/components/AiQuizGenerator.tsx b/apps/frontend/src/components/AiQuizGenerator.tsx index 9658992..5673d26 100644 --- a/apps/frontend/src/components/AiQuizGenerator.tsx +++ b/apps/frontend/src/components/AiQuizGenerator.tsx @@ -1,143 +1,34 @@ -import { useRef, useState } from 'react'; -import { BiTrash, BiUpload } from 'react-icons/bi'; +import { useState } from 'react'; +import { BiX } from 'react-icons/bi'; import { BsStars } from 'react-icons/bs'; -import { LuFileText, LuLoader } from 'react-icons/lu'; -import { - generateQuiz, - genaiErrorMessage, - type GeneratedQuestion, -} from '../services/genaiApi'; - -// A generated question plus the local review state layered on top of it. -interface DraftQuestion extends GeneratedQuestion { - selected: boolean; -} +import type { GeneratedQuestion } from '../services/genaiApi'; +import AskPdfTab from './ai/AskPdfTab'; +import AssistantTab from './ai/AssistantTab'; +import GenerateQuizTab from './ai/GenerateQuizTab'; +import PdfDropzone from './ai/PdfDropzone'; interface Props { + // Texts of questions already in the quiz, so the AI does not repeat them. + existingQuestions: string[]; // Persists one question; the parent owns the quiz API call. onSave: (question: GeneratedQuestion, timeLimit: number) => Promise; onSaved: () => void; onClose: () => void; } -const DIFFICULTY_STYLES: Record = { - Low: 'bg-green-500/10 text-green-400 border-green-500/20', - Medium: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', - High: 'bg-rose-500/10 text-rose-400 border-rose-500/20', -}; +type Tab = 'generate' | 'ask' | 'assistant'; -function AiQuizGenerator({ onSave, onClose, onSaved }: Props) { - const fileInput = useRef(null); +const TABS: Array<{ id: Tab; label: string }> = [ + { id: 'generate', label: 'Generate quiz' }, + { id: 'ask', label: 'Ask the PDF' }, + { id: 'assistant', label: 'Assistant' }, +]; +// All tabs stay mounted so switching tabs, or hiding the panel, keeps +// unsaved drafts, answers and chat history. +function AiQuizGenerator({ existingQuestions, onSave, onClose, onSaved }: Props) { + const [tab, setTab] = useState('generate'); const [file, setFile] = useState(null); - const [prompt, setPrompt] = useState('Generate 5 questions'); - const [timeLimit, setTimeLimit] = useState('15'); - - const [drafts, setDrafts] = useState([]); - const [generating, setGenerating] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(''); - - const selectedCount = drafts.filter((d) => d.selected).length; - - const handlePick = (picked: File | undefined) => { - if (!picked) return; - - if (picked.type !== 'application/pdf') { - setError('Only PDF files are supported.'); - return; - } - - setError(''); - setFile(picked); - }; - - const handleGenerate = async () => { - if (!file) return setError('Choose a PDF first.'); - if (!prompt.trim()) return setError('Describe what to generate.'); - - setGenerating(true); - setError(''); - - try { - const questions = await generateQuiz(file, prompt); - - setDrafts(questions.map((q) => ({ ...q, selected: true }))); - } catch (err) { - setError(genaiErrorMessage(err, 'Failed to generate questions.')); - } finally { - setGenerating(false); - } - }; - - const updateDraft = (idx: number, patch: Partial) => { - setDrafts((prev) => - prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)) - ); - }; - - const setCorrect = (qIdx: number, optIdx: number) => { - setDrafts((prev) => - prev.map((d, i) => - i === qIdx - ? { - ...d, - options: d.options.map((o, oi) => ({ - ...o, - isCorrect: oi === optIdx, - })), - } - : d - ) - ); - }; - - const setOptionText = (qIdx: number, optIdx: number, text: string) => { - setDrafts((prev) => - prev.map((d, i) => - i === qIdx - ? { - ...d, - options: d.options.map((o, oi) => - oi === optIdx ? { ...o, text } : o - ), - } - : d - ) - ); - }; - - const handleSaveSelected = async () => { - const chosen = drafts.filter((d) => d.selected); - - if (chosen.length === 0) return setError('Select at least one question.'); - - const invalid = chosen.find( - (q) => !q.text.trim() || q.options.some((o) => !o.text.trim()) - ); - - if (invalid) return setError('Every question and option needs text.'); - - setSaving(true); - setError(''); - - try { - // Saved sequentially: the API takes one question per request, - // and this keeps ordering stable in the quiz. - for (const q of chosen) { - await onSave(q, parseInt(timeLimit) || 15); - } - - setDrafts([]); - setFile(null); - onSaved(); - onClose(); - } catch { - setError('Saved some questions, but one failed. Check the list.'); - } finally { - setSaving(false); - } - }; return (
@@ -146,219 +37,57 @@ function AiQuizGenerator({ onSave, onClose, onSaved }: Props) {

Generate with AI

- - Upload a PDF and describe what you want - +
- {/* Upload + prompt */} -
-
- handlePick(e.target.files?.[0])} - /> +
+ {TABS.map((t) => ( -
- - setPrompt(e.target.value)} - /> - - + ))}
- {generating && ( -

- Reading the PDF and writing questions. This can take up to a - minute for large documents. -

- )} - - {error && ( -

- {error} -

- )} - - {/* Review step */} - {drafts.length > 0 && ( -
-
-

- Review{' '} - - {selectedCount} - {' '} - of {drafts.length} selected -

- -
-
- setTimeLimit(e.target.value)} - /> - - SECS EACH - -
- - -
-
- -
- {drafts.map((q, qIdx) => ( -
-
- - updateDraft(qIdx, { - selected: e.target.checked, - }) - } - className="mt-2 w-4 h-4 accent-purple-500 cursor-pointer shrink-0" - /> - - - updateDraft(qIdx, { - text: e.target.value, - }) - } - className="flex-1 bg-transparent border-b border-transparent hover:border-gray-700 focus:border-purple-600 outline-none font-secondary text-gray-200 tracking-wide font-semibold pb-1" - /> - - - {q.difficulty} - - - -
- -
- {q.options.map((opt, optIdx) => ( -
- - - setOptionText( - qIdx, - optIdx, - e.target.value - ) - } - className="w-full bg-transparent px-3 py-2.5 font-secondary text-sm text-zinc-300 focus:outline-none" - /> -
- ))} -
-
- ))} -
+ {tab !== 'assistant' && ( +
+
)} + + + +
); } diff --git a/apps/frontend/src/components/ai/AiProgress.tsx b/apps/frontend/src/components/ai/AiProgress.tsx new file mode 100644 index 0000000..a2437a4 --- /dev/null +++ b/apps/frontend/src/components/ai/AiProgress.tsx @@ -0,0 +1,47 @@ +import { LuLoader } from 'react-icons/lu'; + +interface Props { + // What the model is doing once the upload has finished. + label: string; + uploadPercent: number | null; + elapsed: number; + onCancel: () => void; +} + +function AiProgress({ label, uploadPercent, elapsed, onCancel }: Props) { + const uploading = uploadPercent !== null && uploadPercent < 100; + + return ( +
+
+ + {uploading ? ( + Uploading PDF… {uploadPercent}% + ) : ( + + {label}… {elapsed}s + {elapsed >= 20 && ( + + {' '} + (large documents can take up to a minute) + + )} + + )} +
+ + +
+ ); +} + +export default AiProgress; diff --git a/apps/frontend/src/components/ai/AskPdfTab.tsx b/apps/frontend/src/components/ai/AskPdfTab.tsx new file mode 100644 index 0000000..b558de6 --- /dev/null +++ b/apps/frontend/src/components/ai/AskPdfTab.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { LuMessageSquareText } from 'react-icons/lu'; +import { askPdf, genaiErrorMessage, isCancelled } from '../../services/genaiApi'; +import AiProgress from './AiProgress'; +import { useAiRequest } from './useAiRequest'; + +interface Props { + file: File | null; +} + +interface Exchange { + id: number; + question: string; + answer: string; +} + +let nextExchangeId = 1; + +// Question answering over the uploaded PDF, handy for checking what a +// document covers before generating questions from it. +function AskPdfTab({ file }: Props) { + const [question, setQuestion] = useState(''); + const [history, setHistory] = useState([]); + const [error, setError] = useState(''); + + const request = useAiRequest(); + + const handleAsk = async (e: React.FormEvent) => { + e.preventDefault(); + + const asked = question.trim(); + + if (!file) return setError('Choose a PDF first.'); + if (!asked) return setError('Type a question about the PDF.'); + + setError(''); + + try { + const { answer } = await request.run((options) => askPdf(file, asked, options)); + + setHistory((prev) => [{ id: nextExchangeId++, question: asked, answer }, ...prev]); + setQuestion(''); + } catch (err) { + if (!isCancelled(err)) { + setError(genaiErrorMessage(err, 'Failed to answer the question.')); + } + } + }; + + return ( +
+
+ setQuestion(e.target.value)} + disabled={request.busy} + /> + +
+ + {request.busy && ( + + )} + + {error && ( +

+ {error} +

+ )} + +
+ {history.map((item) => ( +
+

+ {item.question} +

+

+ {item.answer} +

+
+ ))} +
+
+ ); +} + +export default AskPdfTab; diff --git a/apps/frontend/src/components/ai/AssistantTab.tsx b/apps/frontend/src/components/ai/AssistantTab.tsx new file mode 100644 index 0000000..094e221 --- /dev/null +++ b/apps/frontend/src/components/ai/AssistantTab.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { BiSend } from 'react-icons/bi'; +import { chat, genaiErrorMessage, isCancelled } from '../../services/genaiApi'; +import AiProgress from './AiProgress'; +import { useAiRequest } from './useAiRequest'; + +interface Message { + id: number; + role: 'user' | 'assistant'; + text: string; +} + +let nextMessageId = 1; + +// General AI assistant for brainstorming quiz content. It searches the web +// when a question needs current information. Each message is answered on +// its own; the service does not keep conversation memory. +function AssistantTab() { + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([]); + const [error, setError] = useState(''); + + const request = useAiRequest(); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + + const text = input.trim(); + + if (!text) return; + + setError(''); + setInput(''); + setMessages((prev) => [...prev, { id: nextMessageId++, role: 'user', text }]); + + try { + const reply = await request.run((options) => chat(text, options)); + + setMessages((prev) => [ + ...prev, + { id: nextMessageId++, role: 'assistant', text: reply }, + ]); + } catch (err) { + if (!isCancelled(err)) { + setError(genaiErrorMessage(err, 'The assistant failed to reply.')); + } + } + }; + + return ( +
+ {messages.length === 0 ? ( +

+ Brainstorm quiz ideas, check facts, or ask about recent events. The + assistant searches the web when it needs current information. Each + message is answered on its own, so include the context you need. +

+ ) : ( +
+ {messages.map((m) => ( +
+ {m.text} +
+ ))} +
+ )} + +
+ setInput(e.target.value)} + disabled={request.busy} + /> + + {messages.length > 0 && ( + + )} +
+ + {request.busy && ( + + )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} + +export default AssistantTab; diff --git a/apps/frontend/src/components/ai/GenerateQuizTab.tsx b/apps/frontend/src/components/ai/GenerateQuizTab.tsx new file mode 100644 index 0000000..9366a2c --- /dev/null +++ b/apps/frontend/src/components/ai/GenerateQuizTab.tsx @@ -0,0 +1,505 @@ +import { useState } from 'react'; +import { BiTrash } from 'react-icons/bi'; +import { BsStars } from 'react-icons/bs'; +import { LuLoader, LuRefreshCw } from 'react-icons/lu'; +import { + generateQuiz, + genaiErrorMessage, + isCancelled, + type Difficulty, + type GeneratedQuestion, + type QuizSettings, +} from '../../services/genaiApi'; +import AiProgress from './AiProgress'; +import { DIFFICULTY_STYLES } from './difficulty'; +import { useAiRequest } from './useAiRequest'; + +// A generated question plus the local review state layered on top of it. +interface DraftQuestion extends GeneratedQuestion { + id: number; + selected: boolean; +} + +interface Props { + file: File | null; + // Texts of questions already in the quiz, so the AI does not repeat them. + existingQuestions: string[]; + // Persists one question; the parent owns the quiz API call. + onSave: (question: GeneratedQuestion, timeLimit: number) => Promise; + onSaved: () => void; + onClose: () => void; +} + +const COUNTS = [3, 5, 10, 15, 20]; +const DIFFICULTIES: Array = ['Mixed', 'Low', 'Medium', 'High']; +const TIME_PRESETS = [10, 15, 20, 30]; +const MIN_TIME = 5; +const MAX_TIME = 300; + +let nextDraftId = 1; + +const toDraft = (q: GeneratedQuestion): DraftQuestion => ({ + ...q, + id: nextDraftId++, + selected: true, +}); + +const chip = (active: boolean) => + `py-1.5 px-3 rounded-lg border text-sm font-secondary font-bold tracking-wide cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${ + active + ? 'bg-purple-600/40 border-purple-500 text-purple-100' + : 'bg-transparent border-purple-800/50 text-zinc-400 hover:bg-purple-900/20' + }`; + +function GenerateQuizTab({ file, existingQuestions, onSave, onSaved, onClose }: Props) { + const [count, setCount] = useState(5); + const [difficulty, setDifficulty] = useState('Mixed'); + const [topic, setTopic] = useState(''); + const [timeLimit, setTimeLimit] = useState('15'); + + const [drafts, setDrafts] = useState([]); + const [regeneratingId, setRegeneratingId] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const generation = useAiRequest(); + const regeneration = useAiRequest(); + + const selectedCount = drafts.filter((d) => d.selected).length; + const busy = generation.busy || regeneration.busy || saving; + + // Everything the model should avoid repeating: saved and pending questions. + const knownTexts = () => [...existingQuestions, ...drafts.map((d) => d.text)]; + + const updateDraft = (id: number, patch: Partial) => { + setDrafts((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d))); + }; + + const setCorrect = (id: number, optIdx: number) => { + setDrafts((prev) => + prev.map((d) => + d.id === id + ? { + ...d, + options: d.options.map((o, oi) => ({ + ...o, + isCorrect: oi === optIdx, + })), + } + : d + ) + ); + }; + + const setOptionText = (id: number, optIdx: number, text: string) => { + setDrafts((prev) => + prev.map((d) => + d.id === id + ? { + ...d, + options: d.options.map((o, oi) => + oi === optIdx ? { ...o, text } : o + ), + } + : d + ) + ); + }; + + const handleGenerate = async () => { + if (!file) return setError('Choose a PDF first.'); + + setError(''); + + try { + const questions = await generation.run((options) => + generateQuiz( + file, + { count, difficulty, topic, exclude: knownTexts() }, + options + ) + ); + + // appended, so generating again never throws away unsaved work + setDrafts((prev) => [...prev, ...questions.map(toDraft)]); + } catch (err) { + if (!isCancelled(err)) { + setError(genaiErrorMessage(err, 'Failed to generate questions.')); + } + } + }; + + const handleRegenerate = async (draft: DraftQuestion) => { + if (!file) return setError('Choose the PDF again to regenerate questions.'); + + setError(''); + setRegeneratingId(draft.id); + + try { + const [replacement] = await regeneration.run((options) => + generateQuiz( + file, + { + count: 1, + difficulty: draft.difficulty, + topic, + exclude: knownTexts(), + }, + options + ) + ); + + if (replacement) { + setDrafts((prev) => + prev.map((d) => (d.id === draft.id ? toDraft(replacement) : d)) + ); + } + } catch (err) { + if (!isCancelled(err)) { + setError(genaiErrorMessage(err, 'Failed to regenerate the question.')); + } + } finally { + setRegeneratingId(null); + } + }; + + const handleSaveSelected = async () => { + const chosen = drafts.filter((d) => d.selected); + + if (chosen.length === 0) return setError('Select at least one question.'); + + const invalid = chosen.find( + (q) => !q.text.trim() || q.options.some((o) => !o.text.trim()) + ); + + if (invalid) return setError('Every question and option needs text.'); + + const seconds = Number(timeLimit); + + if (!Number.isInteger(seconds) || seconds < MIN_TIME || seconds > MAX_TIME) { + return setError( + `Time per question must be a whole number from ${MIN_TIME} to ${MAX_TIME} seconds.` + ); + } + + setSaving(true); + setError(''); + + let saved = 0; + + try { + // Saved sequentially: the API takes one question per request, + // and this keeps ordering stable in the quiz. Each question + // leaves the list as soon as it is stored, so a retry after a + // failure never saves it twice. + for (const q of chosen) { + await onSave(q, seconds); + saved += 1; + setDrafts((prev) => prev.filter((d) => d.id !== q.id)); + } + + onSaved(); + onClose(); + } catch { + if (saved > 0) onSaved(); + + setError( + `Saved ${saved} of ${chosen.length} questions. The rest are still ` + + 'below. Try adding them again.' + ); + } finally { + setSaving(false); + } + }; + + return ( +
+ {/* Settings */} +
+
+
+ + QUESTIONS + + {COUNTS.map((n) => ( + + ))} +
+ +
+ + DIFFICULTY + + {DIFFICULTIES.map((d) => ( + + ))} +
+
+ +
+ setTopic(e.target.value)} + disabled={busy} + /> + + +
+
+ + {generation.busy && ( + + )} + + {error && ( +

+ {error} +

+ )} + + {/* Review step */} + {drafts.length > 0 && ( +
+
+
+

+ Review{' '} + {selectedCount} of{' '} + {drafts.length} selected +

+
+ + + +
+
+ +
+
+ {TIME_PRESETS.map((t) => ( + + ))} + setTimeLimit(e.target.value)} + /> + + SECS EACH + +
+ + +
+
+ +
+ {drafts.map((q) => { + const regenerating = regeneratingId === q.id; + + return ( +
+
+ + updateDraft(q.id, { selected: e.target.checked }) + } + className="mt-2 w-4 h-4 accent-purple-500 cursor-pointer shrink-0" + /> + + updateDraft(q.id, { text: e.target.value })} + className="flex-1 bg-transparent border-b border-transparent hover:border-gray-700 focus:border-purple-600 outline-none font-secondary text-gray-200 tracking-wide font-semibold pb-1" + /> + + + + + + +
+ +
+ {q.options.map((opt, optIdx) => ( +
+ + + setOptionText(q.id, optIdx, e.target.value) + } + className="w-full bg-transparent px-3 py-2.5 font-secondary text-sm text-zinc-300 focus:outline-none" + /> +
+ ))} +
+
+ ); + })} +
+
+ )} +
+ ); +} + +export default GenerateQuizTab; diff --git a/apps/frontend/src/components/ai/PdfDropzone.tsx b/apps/frontend/src/components/ai/PdfDropzone.tsx new file mode 100644 index 0000000..6a1cba4 --- /dev/null +++ b/apps/frontend/src/components/ai/PdfDropzone.tsx @@ -0,0 +1,120 @@ +import { useRef, useState } from 'react'; +import { BiUpload, BiX } from 'react-icons/bi'; +import { LuFileText } from 'react-icons/lu'; +import { MAX_PDF_MB } from '../../services/genaiApi'; + +interface Props { + file: File | null; + onChange: (file: File | null) => void; + disabled?: boolean; +} + +const formatSize = (bytes: number) => + bytes < 1024 * 1024 + ? `${Math.max(1, Math.round(bytes / 1024))} KB` + : `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + +// Checked here so the user finds out instantly, not after an upload. +const validate = (file: File) => { + const isPdf = + file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); + + if (!isPdf) return 'Only PDF files are supported.'; + + if (file.size > MAX_PDF_MB * 1024 * 1024) { + return `This PDF is ${formatSize(file.size)}. The limit is ${MAX_PDF_MB} MB.`; + } + + return ''; +}; + +function PdfDropzone({ file, onChange, disabled }: Props) { + const input = useRef(null); + + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(''); + + const pick = (picked: File | undefined) => { + if (!picked || disabled) return; + + const problem = validate(picked); + setError(problem); + + if (!problem) onChange(picked); + }; + + return ( +
+ { + pick(e.target.files?.[0]); + // allow picking the same file again after removing it + e.target.value = ''; + }} + /> + + {file ? ( +
+ + + {file.name} + + + {formatSize(file.size)} + + +
+ ) : ( + + )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} + +export default PdfDropzone; diff --git a/apps/frontend/src/components/ai/difficulty.ts b/apps/frontend/src/components/ai/difficulty.ts new file mode 100644 index 0000000..c407890 --- /dev/null +++ b/apps/frontend/src/components/ai/difficulty.ts @@ -0,0 +1,7 @@ +import type { Difficulty } from '../../services/genaiApi'; + +export const DIFFICULTY_STYLES: Record = { + Low: 'bg-green-500/10 text-green-400 border-green-500/20', + Medium: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + High: 'bg-rose-500/10 text-rose-400 border-rose-500/20', +}; diff --git a/apps/frontend/src/components/ai/useAiRequest.ts b/apps/frontend/src/components/ai/useAiRequest.ts new file mode 100644 index 0000000..9d876b0 --- /dev/null +++ b/apps/frontend/src/components/ai/useAiRequest.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RequestOptions } from '../../services/genaiApi'; + +// Tracks one in-flight AI request: busy state, upload progress, elapsed +// seconds, and cancellation. Unmounting cancels whatever is running. +export function useAiRequest() { + const controller = useRef(null); + + const [busy, setBusy] = useState(false); + const [uploadPercent, setUploadPercent] = useState(null); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!busy) return; + + setElapsed(0); + const timer = setInterval(() => setElapsed((s) => s + 1), 1000); + + return () => clearInterval(timer); + }, [busy]); + + useEffect(() => () => controller.current?.abort(), []); + + const run = useCallback( + async (request: (options: RequestOptions) => Promise) => { + controller.current?.abort(); + + const current = new AbortController(); + controller.current = current; + + setBusy(true); + setUploadPercent(null); + + try { + return await request({ + signal: current.signal, + onUploadProgress: setUploadPercent, + }); + } finally { + // a newer request may have replaced this one + if (controller.current === current) { + controller.current = null; + setBusy(false); + setUploadPercent(null); + } + } + }, + [] + ); + + const cancel = useCallback(() => controller.current?.abort(), []); + + return { busy, uploadPercent, elapsed, run, cancel }; +} diff --git a/apps/frontend/src/screens/QuizBuilder.test.tsx b/apps/frontend/src/screens/QuizBuilder.test.tsx new file mode 100644 index 0000000..c2ab470 --- /dev/null +++ b/apps/frontend/src/screens/QuizBuilder.test.tsx @@ -0,0 +1,123 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import QuizBuilder from './QuizBuilder'; + +vi.mock('../services/api', () => ({ + api: { get: vi.fn(), post: vi.fn() }, +})); + +vi.mock('../services/genaiApi', async (importOriginal) => ({ + ...(await importOriginal()), + generateQuiz: vi.fn(), +})); + +const { api } = await import('../services/api'); +const { generateQuiz } = await import('../services/genaiApi'); + +const answers = [0, 1, 2, 3].map((i) => ({ id: `a${i}`, text: `Answer ${i}`, isCorrect: i === 0 })); + +const quiz = { + id: 'quiz-1', + title: 'Biology', + status: 'DRAFT', + questions: [ + { id: 'q1', text: 'What is ATP?', timeLimit: 15, difficulty: 'High', answers }, + ], + organizers: [], +}; + +function renderBuilder() { + render( + + + } /> + + + ); + + return userEvent.setup(); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.get).mockResolvedValue({ data: quiz }); + vi.mocked(api.post).mockResolvedValue({ data: {} }); +}); + +describe('QuizBuilder AI integration', () => { + it('shows each question difficulty', async () => { + renderBuilder(); + + const title = await screen.findByText('What is ATP?'); + const card = title.closest('div.group') as HTMLElement; + + expect(within(card).getByText('High')).toBeInTheDocument(); + }); + + it('keeps AI drafts when the panel is closed and reopened', async () => { + const user = renderBuilder(); + await screen.findByText('What is ATP?'); + + await user.click(screen.getByRole('button', { name: /generate with ai/i })); + await user.upload( + screen.getByTestId('pdf-input'), + new File(['%PDF'], 'bio.pdf', { type: 'application/pdf' }) + ); + + vi.mocked(generateQuiz).mockResolvedValueOnce([ + { + text: 'What is a ribosome?', + difficulty: 'Low', + options: answers.map(({ text, isCorrect }) => ({ text, isCorrect })), + }, + ]); + await user.click(screen.getByRole('button', { name: /generate 5 questions/i })); + await screen.findByDisplayValue('What is a ribosome?'); + + // questions already in the quiz are excluded from generation + expect(vi.mocked(generateQuiz).mock.calls[0][1].exclude).toEqual(['What is ATP?']); + + await user.click(screen.getByRole('button', { name: 'Close AI panel' })); + expect(screen.getByDisplayValue('What is a ribosome?')).not.toBeVisible(); + + await user.click(screen.getByRole('button', { name: /generate with ai/i })); + expect(screen.getByDisplayValue('What is a ribosome?')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Add 1 to Quiz' })); + + await waitFor(() => + expect(api.post).toHaveBeenCalledWith('/quizzes/quiz-1/questions', { + text: 'What is a ribosome?', + timeLimit: 15, + difficulty: 'Low', + answers: answers.map(({ text, isCorrect }) => ({ text, isCorrect })), + }) + ); + // the quiz is reloaded so the new question appears + await waitFor(() => expect(api.get).toHaveBeenCalledTimes(2)); + }); + + it('saves the difficulty picked for a manual question', async () => { + const user = renderBuilder(); + await screen.findByText('What is ATP?'); + + await user.click(screen.getByRole('button', { name: /add question/i })); + await user.type(screen.getByPlaceholderText('Add Question Here...'), 'Manual?'); + for (const i of [1, 2, 3, 4]) { + await user.type(screen.getByPlaceholderText(`Answer ${i}`), `A${i}`); + } + await user.click( + within(screen.getByRole('group', { name: 'Difficulty' })).getByRole('button', { name: 'Low' }) + ); + await user.click(screen.getByRole('button', { name: /save question/i })); + + await waitFor(() => + expect(api.post).toHaveBeenCalledWith( + '/quizzes/quiz-1/questions', + expect.objectContaining({ text: 'Manual?', difficulty: 'Low' }) + ) + ); + }); +}); diff --git a/apps/frontend/src/screens/QuizBuilder.tsx b/apps/frontend/src/screens/QuizBuilder.tsx index 34109f4..159eaec 100644 --- a/apps/frontend/src/screens/QuizBuilder.tsx +++ b/apps/frontend/src/screens/QuizBuilder.tsx @@ -6,7 +6,10 @@ import { useEffect, useState } from "react" import { BiPlay, BiPlus, BiSave } from "react-icons/bi" import { LuUserSearch } from "react-icons/lu" import AiQuizGenerator from "../components/AiQuizGenerator" -import type { GeneratedQuestion } from "../services/genaiApi" +import type { Difficulty, GeneratedQuestion } from "../services/genaiApi" +import { DIFFICULTY_STYLES } from "../components/ai/difficulty" + +const DIFFICULTIES: Difficulty[] = ['Low', 'Medium', 'High']; function QuizBuilder() { @@ -25,6 +28,7 @@ function QuizBuilder() { const [showAiGen, setShowAiGen] = useState(false); const [qText, setQText] = useState(''); const [qTime, setQTime] = useState('15'); + const [qDifficulty, setQDifficulty] = useState('Medium'); const [answers, setAnswers] = useState([{ text: '', isCorrect: true }, { text: '', isCorrect: false }, { text: '', isCorrect: false }, { text: '', isCorrect: false }]); const [inviteEmail, setInviteEmail] = useState(''); @@ -112,6 +116,7 @@ function QuizBuilder() { await api.post(`/quizzes/${id}/questions`, { text: q.text, timeLimit, + difficulty: q.difficulty, answers: q.options.map(o => ({ text: o.text, isCorrect: o.isCorrect })) }); }; @@ -125,11 +130,13 @@ function QuizBuilder() { await api.post(`/quizzes/${id}/questions`, { text: qText, timeLimit: parseInt(qTime) || 15, + difficulty: qDifficulty, answers }); setShowAddQ(false); setQText(''); setQTime('15'); + setQDifficulty('Medium'); setAnswers([{ text: '', isCorrect: true }, { text: '', isCorrect: false }, { text: '', isCorrect: false }, { text: '', isCorrect: false }]); fetchQuiz(); } catch (err) { @@ -201,13 +208,15 @@ function QuizBuilder() {
- {showAiGen && ( + {/* kept mounted while hidden so closing it does not lose drafts */} + {showAddQ && (
@@ -227,6 +236,21 @@ function QuizBuilder() {
+
+ DIFFICULTY + {DIFFICULTIES.map(d => ( + + ))} +
+
{answers.map((ans, idx) => (
@@ -261,6 +285,9 @@ function QuizBuilder() {

{idx + 1}. {q.text}

+ {q.difficulty && ( + {q.difficulty} + )} {q.timeLimit}s
diff --git a/apps/frontend/src/services/genaiApi.test.ts b/apps/frontend/src/services/genaiApi.test.ts new file mode 100644 index 0000000..aa331c3 --- /dev/null +++ b/apps/frontend/src/services/genaiApi.test.ts @@ -0,0 +1,76 @@ +import { AxiosError, CanceledError } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import { genaiApi, genaiErrorMessage, generateQuiz, isCancelled } from './genaiApi'; + +describe('generateQuiz', () => { + it('sends the settings as multipart form fields', async () => { + const post = vi + .spyOn(genaiApi, 'post') + .mockResolvedValue({ data: { questions: [{ text: 'Q' }] } }); + const file = new File(['%PDF'], 'notes.pdf', { type: 'application/pdf' }); + const controller = new AbortController(); + const onUploadProgress = vi.fn(); + + const questions = await generateQuiz( + file, + { count: 3, difficulty: 'Low', topic: 'cells', exclude: ['Old?'] }, + { signal: controller.signal, onUploadProgress } + ); + + expect(questions).toEqual([{ text: 'Q' }]); + + const [url, form, config] = post.mock.calls[0] as [ + string, + FormData, + { + signal: AbortSignal; + onUploadProgress: (e: { loaded: number; total: number }) => void; + }, + ]; + expect(url).toBe('/generate-quiz'); + expect(form.get('file')).toBeInstanceOf(File); + expect(form.get('count')).toBe('3'); + expect(form.get('difficulty')).toBe('Low'); + expect(form.get('user_query')).toBe('cells'); + expect(JSON.parse(form.get('exclude') as string)).toEqual(['Old?']); + expect(config.signal).toBe(controller.signal); + + config.onUploadProgress({ loaded: 50, total: 200 }); + expect(onUploadProgress).toHaveBeenCalledWith(25); + }); +}); + +describe('genaiErrorMessage', () => { + const withResponse = (status: number, data: unknown) => + new AxiosError('failed', 'ERR_BAD_RESPONSE', undefined, undefined, { + status, + data, + statusText: '', + headers: {}, + config: {} as never, + }); + + it('uses the FastAPI detail string', () => { + expect(genaiErrorMessage(withResponse(413, { detail: 'PDF is too large.' }), 'x')).toBe( + 'PDF is too large.' + ); + }); + + it('uses the first validation error message', () => { + const err = withResponse(422, { detail: [{ msg: 'Input should be less than or equal to 20' }] }); + expect(genaiErrorMessage(err, 'x')).toBe('Input should be less than or equal to 20'); + }); + + it('explains an unreachable service', () => { + expect(genaiErrorMessage(new AxiosError('Network Error'), 'x')).toMatch(/could not reach/i); + }); + + it('falls back for unknown errors', () => { + expect(genaiErrorMessage(new Error('?'), 'fallback')).toBe('fallback'); + }); + + it('recognises cancellations', () => { + expect(isCancelled(new CanceledError())).toBe(true); + expect(isCancelled(new Error('no'))).toBe(false); + }); +}); diff --git a/apps/frontend/src/services/genaiApi.ts b/apps/frontend/src/services/genaiApi.ts index fdd80b1..841deac 100644 --- a/apps/frontend/src/services/genaiApi.ts +++ b/apps/frontend/src/services/genaiApi.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { type AxiosProgressEvent } from 'axios'; const genaiHost = import.meta.env.VITE_GENAI_URL || 'http://localhost:8000'; @@ -10,14 +10,35 @@ export const genaiApi = axios.create({ timeout: 120000, }); +// Must match MAX_UPLOAD_MB in apps/genAI/app/main.py. +export const MAX_PDF_MB = 10; + +export type Difficulty = 'Low' | 'Medium' | 'High'; + +// Cancellation and upload progress for the slow, file-carrying calls. +export interface RequestOptions { + signal?: AbortSignal; + onUploadProgress?: (percent: number) => void; +} + +const requestConfig = ({ signal, onUploadProgress }: RequestOptions = {}) => ({ + signal, + onUploadProgress: onUploadProgress + ? (e: AxiosProgressEvent) => + onUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) + : undefined, +}); + export interface ChatResponse { response: string; } -export const chat = async (userQuery: string) => { - const { data } = await genaiApi.post('/chat', { - user_query: userQuery, - }); +export const chat = async (userQuery: string, options?: RequestOptions) => { + const { data } = await genaiApi.post( + '/chat', + { user_query: userQuery }, + requestConfig(options) + ); return data.response; }; @@ -48,12 +69,20 @@ export interface AskPdfResponse { answer: string; } -export const askPdf = async (file: File, userQuery: string) => { +export const askPdf = async ( + file: File, + userQuery: string, + options?: RequestOptions +) => { const form = new FormData(); form.append('file', file); form.append('user_query', userQuery); - const { data } = await genaiApi.post('/ask-pdf', form); + const { data } = await genaiApi.post( + '/ask-pdf', + form, + requestConfig(options) + ); return data; }; @@ -65,7 +94,7 @@ export interface GeneratedOption { export interface GeneratedQuestion { text: string; - difficulty: 'Low' | 'Medium' | 'High'; + difficulty: Difficulty; options: GeneratedOption[]; } @@ -76,21 +105,40 @@ export interface GenerateQuizResponse { questions: GeneratedQuestion[]; } +export interface QuizSettings { + count: number; + difficulty: Difficulty | 'Mixed'; + // Optional focus, e.g. "chapter 2" or "only the formulas". + topic: string; + // Question texts the model must not repeat. + exclude?: string[]; +} + // Structured counterpart to generateQuestions: returns data the quiz // builder can render and save, rather than a formatted text blob. -export const generateQuiz = async (file: File, userQuery: string) => { +export const generateQuiz = async ( + file: File, + settings: QuizSettings, + options?: RequestOptions +) => { const form = new FormData(); form.append('file', file); - form.append('user_query', userQuery); + form.append('user_query', settings.topic); + form.append('count', String(settings.count)); + form.append('difficulty', settings.difficulty); + form.append('exclude', JSON.stringify(settings.exclude ?? [])); const { data } = await genaiApi.post( '/generate-quiz', - form + form, + requestConfig(options) ); return data.questions; }; +export const isCancelled = (err: unknown) => axios.isCancel(err); + // Surfaces the FastAPI "detail" field, which carries the actionable // message (missing API key, unusable PDF, and so on). export const genaiErrorMessage = (err: unknown, fallback: string) => { @@ -99,6 +147,11 @@ export const genaiErrorMessage = (err: unknown, fallback: string) => { if (typeof detail === 'string') return detail; + // FastAPI validation errors carry a list of problems + if (Array.isArray(detail) && typeof detail[0]?.msg === 'string') { + return detail[0].msg; + } + if (err.code === 'ECONNABORTED') { return 'The request timed out. Try asking for fewer questions.'; } diff --git a/apps/frontend/src/test/setup.ts b/apps/frontend/src/test/setup.ts new file mode 100644 index 0000000..1443952 --- /dev/null +++ b/apps/frontend/src/test/setup.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => cleanup()); diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 7bff3d9..0818dac 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -7,4 +8,8 @@ export default defineConfig({ plugins: [react(), tailwindcss() ], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, }) diff --git a/apps/genAI/Dockerfile b/apps/genAI/Dockerfile index f759888..f0938cf 100644 --- a/apps/genAI/Dockerfile +++ b/apps/genAI/Dockerfile @@ -5,18 +5,14 @@ WORKDIR /app/apps/genAI ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ HF_HOME=/models \ + FASTEMBED_CACHE_PATH=/models \ PIP_DEFAULT_TIMEOUT=120 \ PIP_RETRIES=10 -# build deps for faiss / sentence-transformers wheels -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential curl \ - && rm -rf /var/lib/apt/lists/* - COPY apps/genAI/requirements.txt ./requirements.txt -# cache mount keeps already-downloaded wheels across retries, so a dropped -# connection does not restart the whole (multi-GB) download +# every dependency ships a prebuilt wheel, so no compiler toolchain is +# needed; the cache mount keeps downloaded wheels across rebuilds RUN --mount=type=cache,target=/root/.cache/pip \ pip install --upgrade pip \ && pip install --resume-retries 10 -r requirements.txt diff --git a/apps/genAI/Dockerfile.prod b/apps/genAI/Dockerfile.prod index c47aadf..8ebc549 100644 --- a/apps/genAI/Dockerfile.prod +++ b/apps/genAI/Dockerfile.prod @@ -3,6 +3,7 @@ FROM python:3.11-slim AS base ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ HF_HOME=/models \ + FASTEMBED_CACHE_PATH=/models \ PIP_DEFAULT_TIMEOUT=120 \ PIP_RETRIES=10 @@ -12,10 +13,6 @@ WORKDIR /app/apps/genAI # dependencies stage FROM base AS deps -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ - && rm -rf /var/lib/apt/lists/* - COPY apps/genAI/requirements.txt ./requirements.txt RUN --mount=type=cache,target=/root/.cache/pip \ @@ -30,31 +27,30 @@ COPY apps/genAI/app/rag/rag_system.py /tmp/rag_system.py RUN MODEL=$(grep -oP 'EMBEDDING_MODEL = "\K[^"]+' /tmp/rag_system.py) \ && echo "Baking embedding model: $MODEL" \ && /venv/bin/python -c "\ -import sys; from sentence_transformers import SentenceTransformer; \ -SentenceTransformer(sys.argv[1])" "$MODEL" \ - && rm /tmp/rag_system.py +import sys; from fastembed import TextEmbedding; \ +TextEmbedding(sys.argv[1])" "$MODEL" \ + && rm /tmp/rag_system.py \ + && /venv/bin/pip uninstall -y pip setuptools # prod stage FROM base AS runner ENV PATH="/venv/bin:$PATH" \ - UPLOAD_DIR=/data/uploads + UPLOAD_DIR=/data/uploads \ + HF_HUB_OFFLINE=1 -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* +# run as an unprivileged user. Ownership is set with COPY --chown rather +# than chown -R, which would duplicate the whole model into a new layer. +RUN useradd --create-home --uid 1001 genai \ + && mkdir -p /data/uploads \ + && chown genai:genai /data/uploads COPY --from=deps /venv /venv -COPY --from=deps /models /models +COPY --from=deps --chown=genai:genai /models /models COPY apps/genAI/app ./app -# run as an unprivileged user -RUN useradd --create-home --uid 1001 genai \ - && mkdir -p /data/uploads \ - && chown -R genai:genai /data/uploads /models - USER genai EXPOSE 8000 diff --git a/apps/genAI/README.md b/apps/genAI/README.md index e4b2ef2..5e07744 100644 --- a/apps/genAI/README.md +++ b/apps/genAI/README.md @@ -13,7 +13,9 @@ apps/genAI/ │ ├── rag/ # PDF loading, chunking, embeddings, retrieval │ ├── prompts/ # prompt templates │ └── utils/ # config and LLM clients +├── tests/ # pytest suite (LLMs and embeddings are faked) ├── requirements.txt +├── requirements-dev.txt ├── Dockerfile # dev (hot reload) └── Dockerfile.prod # production (venv, baked model, non-root) ``` @@ -22,8 +24,9 @@ apps/genAI/ ```bash cp .env.example .env # then fill in your API keys -pnpm run setup # creates .venv, installs requirements +pnpm run setup # creates .venv, installs requirements + test tools pnpm run dev # http://localhost:8000 +pnpm run test # runs the pytest suite, no API keys needed ``` Interactive API docs: http://localhost:8000/docs @@ -36,7 +39,8 @@ Interactive API docs: http://localhost:8000/docs | `GOOGLE_API_KEY` | Gemini key, used by the chat agent | | `TAVILY_API_KEY` | Tavily key, used for web search | | `GENAI_PORT` | Port to listen on (default `8000`) | -| `UPLOAD_DIR` | Where uploaded PDFs are stored | +| `UPLOAD_DIR` | Where uploaded PDFs are held during a request | +| `MAX_UPLOAD_MB` | Largest PDF accepted (default `10`) | | `ALLOWED_ORIGINS` | Comma-separated CORS origins, or `*` | ## Endpoints @@ -47,6 +51,10 @@ Interactive API docs: http://localhost:8000/docs | POST | `/chat` | JSON `{ user_query }` | | POST | `/generate-questions` | multipart `file`, `user_query` | | POST | `/ask-pdf` | multipart `file`, `user_query` | +| POST | `/generate-quiz` | multipart `file`, optional `user_query` (focus), `count` (1–20, default 5), `difficulty` (`Low`/`Medium`/`High`/`Mixed`), `exclude` (JSON list of question texts not to repeat) | + +PDF endpoints return `413` for files over `MAX_UPLOAD_MB` and `422` for PDFs +with no selectable text (e.g. scanned images). ## Docker @@ -56,8 +64,10 @@ Both compose files include this service (`genai`). Built from the repo root: docker compose up --build genai ``` -The production image bakes the `all-mpnet-base-v2` embedding model in at -build time, so containers don't download it on cold start. +The production image bakes the `bge-small-en-v1.5` embedding model in at +build time, so containers don't download it on cold start. Embeddings run on +CPU through fastembed (ONNX Runtime) rather than torch, which keeps the image +under 1 GB and the running service around 300 MB of RAM. ## Deployment notes @@ -84,5 +94,5 @@ ALLOWED_ORIGINS=https://rexial.in `ALLOWED_ORIGINS` should be the real frontend origin in production — the `*` default is only for local development. -Note: PDF uploads are written to the `genai_uploads` volume and are never -cleaned up automatically. Consider a retention job if usage grows. +Note: PDF uploads are written to the `genai_uploads` volume under a unique +name only for the duration of a request, and deleted once it finishes. diff --git a/apps/genAI/app/agent/Qnagent.py b/apps/genAI/app/agent/Qnagent.py index f495afb..03ef15d 100644 --- a/apps/genAI/app/agent/Qnagent.py +++ b/apps/genAI/app/agent/Qnagent.py @@ -22,6 +22,22 @@ def get_search(): return _search +def content_text(content) -> str: + """Flatten a chat model reply to plain text. + + Gemini returns content as a string OR as a list of parts (strings or + dicts with a "text" key), depending on the model and response. + """ + + if isinstance(content, list): + return "".join( + item.get("text", "") if isinstance(item, dict) else str(item) + for item in content + ) + + return str(content) + + def chat(user_query: str): # Decide whether web search is required @@ -61,22 +77,7 @@ def chat(user_query: str): [HumanMessage(content=router_prompt)] ) - # Gemini content can be string OR list - decision_content = decision_response.content - - if isinstance(decision_content, list): - decision = "" - - for item in decision_content: - if isinstance(item, dict): - decision += item.get("text", "") - else: - decision += str(item) - - decision = decision.strip().upper() - - else: - decision = str(decision_content).strip().upper() + decision = content_text(decision_response.content).strip().upper() # Web search required @@ -105,7 +106,7 @@ def chat(user_query: str): [HumanMessage(content=prompt)] ) - return response.content + return content_text(response.content) # Normal question @@ -113,4 +114,4 @@ def chat(user_query: str): HumanMessage(content=user_query) ]) - return response.content \ No newline at end of file + return content_text(response.content) \ No newline at end of file diff --git a/apps/genAI/app/main.py b/apps/genAI/app/main.py index 38a90c2..10467c4 100644 --- a/apps/genAI/app/main.py +++ b/apps/genAI/app/main.py @@ -1,5 +1,8 @@ +import json import os -import shutil +import uuid +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile @@ -8,7 +11,7 @@ from pydantic import BaseModel from app.agent.Qnagent import chat -from app.rag.rag_system import ask_pdf, generate_questions, generate_quiz +from app.rag.rag_system import PdfHasNoText, ask_pdf, generate_questions, generate_quiz from app.utils.config import MissingAPIKey from app.utils.errors import provider_http_error @@ -40,15 +43,36 @@ async def missing_api_key_handler(request: Request, exc: MissingAPIKey): ) +@app.exception_handler(PdfHasNoText) +async def pdf_has_no_text_handler(request: Request, exc: PdfHasNoText): + return JSONResponse( + status_code=422, + content={"detail": str(exc)}, + ) + + UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "uploads")) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +# Large PDFs blow past the model's context window and the client's +# timeout anyway, so reject them up front with a clear message. +MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "10")) +MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024 + +MAX_QUIZ_QUESTIONS = 20 + class ChatRequest(BaseModel): user_query: str -def save_upload(file: UploadFile) -> Path: - """Persist an uploaded PDF under UPLOAD_DIR using a safe filename.""" +@contextmanager +def saved_upload(file: UploadFile) -> Iterator[Path]: + """Write an uploaded PDF under UPLOAD_DIR for the duration of a request. + + Each upload gets a unique name so concurrent requests with the same + filename cannot overwrite each other, and the file is deleted + afterwards so the uploads volume does not grow without bound. + """ if file.content_type != "application/pdf": raise HTTPException( @@ -56,21 +80,28 @@ def save_upload(file: UploadFile) -> Path: detail="Only PDF files are allowed." ) - # strip any directory components a client may have sent - safe_name = Path(file.filename or "upload.pdf").name + file_path = UPLOAD_DIR / f"{uuid.uuid4().hex}.pdf" - if not safe_name: - raise HTTPException( - status_code=400, - detail="Invalid file name." - ) + try: + written = 0 - file_path = UPLOAD_DIR / safe_name + with open(file_path, "wb") as buffer: + # copy in chunks so an oversized upload is rejected without + # ever being written to disk in full + while chunk := file.file.read(1024 * 1024): + written += len(chunk) - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) + if written > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"PDF is too large. The limit is {MAX_UPLOAD_MB} MB." + ) - return file_path + buffer.write(chunk) + + yield file_path + finally: + file_path.unlink(missing_ok=True) @app.get("/") @@ -103,17 +134,16 @@ async def generate_pdf_questions( user_query: str = Form(...) ): - file_path = save_upload(file) - - try: - questions = generate_questions( - str(file_path), - user_query - ) - except MissingAPIKey: - raise - except Exception as exc: - raise provider_http_error(exc) from exc + with saved_upload(file) as file_path: + try: + questions = generate_questions( + str(file_path), + user_query + ) + except (MissingAPIKey, PdfHasNoText): + raise + except Exception as exc: + raise provider_http_error(exc) from exc return { "message": "Questions generated successfully", @@ -129,17 +159,16 @@ async def ask_pdf_question( user_query: str = Form(...) ): - file_path = save_upload(file) - - try: - answer = ask_pdf( - str(file_path), - user_query - ) - except MissingAPIKey: - raise - except Exception as exc: - raise provider_http_error(exc) from exc + with saved_upload(file) as file_path: + try: + answer = ask_pdf( + str(file_path), + user_query + ) + except (MissingAPIKey, PdfHasNoText): + raise + except Exception as exc: + raise provider_http_error(exc) from exc return { "message": "Answer generated successfully", @@ -148,31 +177,67 @@ async def ask_pdf_question( "answer": answer } +def parse_exclude(raw: str) -> list[str]: + """Decode the JSON list of question texts the model must not repeat.""" + + if not raw: + return [] + + try: + value = json.loads(raw) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=400, + detail="exclude must be a JSON list of strings." + ) from exc + + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise HTTPException( + status_code=400, + detail="exclude must be a JSON list of strings." + ) + + return value + + @app.post("/generate-quiz") async def generate_quiz_endpoint( file: UploadFile = File(...), - user_query: str = Form(...) + user_query: str = Form(""), + count: int = Form(5, ge=1, le=MAX_QUIZ_QUESTIONS), + difficulty: str = Form("Mixed"), + exclude: str = Form(""), ): """Generate multiple-choice questions as structured JSON. Unlike /generate-questions, which returns a formatted text blob for humans to read, this returns data the quiz builder can render and save directly. - """ - file_path = save_upload(file) + `user_query` is an optional focus (topic, chapter); `exclude` is a JSON + list of question texts not to repeat, used to regenerate one question. + """ - try: - questions = generate_quiz(str(file_path), user_query) - except ValueError as exc: - raise HTTPException( - status_code=502, - detail=f"The model returned an unusable response: {exc}" - ) from exc - except MissingAPIKey: - raise - except Exception as exc: - raise provider_http_error(exc) from exc + excluded = parse_exclude(exclude) + + with saved_upload(file) as file_path: + try: + questions = generate_quiz( + str(file_path), + user_query, + count=count, + difficulty=difficulty, + exclude=excluded, + ) + except ValueError as exc: + raise HTTPException( + status_code=502, + detail=f"The model returned an unusable response: {exc}" + ) from exc + except (MissingAPIKey, PdfHasNoText): + raise + except Exception as exc: + raise provider_http_error(exc) from exc if not questions: raise HTTPException( diff --git a/apps/genAI/app/prompts/quiz_json_prompt.py b/apps/genAI/app/prompts/quiz_json_prompt.py index 3c3f33f..20a64b6 100644 --- a/apps/genAI/app/prompts/quiz_json_prompt.py +++ b/apps/genAI/app/prompts/quiz_json_prompt.py @@ -3,7 +3,7 @@ Generate multiple-choice questions from the PDF context below. -USER QUERY: +FOCUS (what the host wants the questions to be about): {user_query} PDF CONTEXT: @@ -11,17 +11,18 @@ Rules: -- Work out how many questions the user asked for from the USER QUERY. - If they ask for 5, generate exactly 5. If no number is given, generate 10. +- Generate exactly {count} questions. - Base every question ONLY on the provided PDF context. No outside knowledge. - Cover different concepts; do not repeat or near-repeat a question. -- Mix low, medium and high difficulty. +- {difficulty_rule} - Every question has exactly 4 options. - Exactly ONE option has "isCorrect": true. The other three are false. - The wrong options must be plausible, not obviously absurd. - Vary which position holds the correct option. - "difficulty" must be exactly one of: "Low", "Medium", "High". +{exclude_rule} + Return ONLY valid JSON matching this shape, with no markdown fences and no commentary before or after: diff --git a/apps/genAI/app/rag/rag_system.py b/apps/genAI/app/rag/rag_system.py index 6271037..fed2205 100644 --- a/apps/genAI/app/rag/rag_system.py +++ b/apps/genAI/app/rag/rag_system.py @@ -1,20 +1,46 @@ import json import re -from langchain_community.document_loaders import PyPDFLoader -from langchain_community.vectorstores import InMemoryVectorStore -from langchain_huggingface import HuggingFaceEmbeddings +from langchain_core.documents import Document +from langchain_core.embeddings import Embeddings +from langchain_core.vectorstores import InMemoryVectorStore from langchain_text_splitters import RecursiveCharacterTextSplitter +from pypdf import PdfReader from app.prompts.quiz_json_prompt import QUIZ_JSON_PROMPT from app.prompts.rag_Qna_generate_prompt import QUESTION_GENERATION_PROMPT from app.utils.llm import get_groq_llm -EMBEDDING_MODEL = "sentence-transformers/all-mpnet-base-v2" +# Small ONNX model run by fastembed. It avoids pulling in torch (and its +# multi-GB CUDA wheels) and needs a fraction of the RAM of a torch model. +# Vectors are never persisted, so changing the model needs no migration. +EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5" + +DIFFICULTIES = ("Low", "Medium", "High") _embeddings = None +class PdfHasNoText(Exception): + """Raised when a PDF has no extractable text, e.g. a scanned document.""" + + +class FastEmbedEmbeddings(Embeddings): + """LangChain adapter over fastembed's ONNX text embedding model.""" + + def __init__(self, model_name: str): + from fastembed import TextEmbedding + + self._model = TextEmbedding(model_name=model_name) + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return [vector.tolist() for vector in self._model.embed(texts)] + + def embed_query(self, text: str) -> list[float]: + # query_embed applies the query instruction prefix bge expects + return next(iter(self._model.query_embed(text))).tolist() + + def get_embeddings(): """Load the embedding model on first use. @@ -26,24 +52,39 @@ def get_embeddings(): global _embeddings if _embeddings is None: - _embeddings = HuggingFaceEmbeddings( - model_name=EMBEDDING_MODEL - ) + _embeddings = FastEmbedEmbeddings(EMBEDDING_MODEL) return _embeddings -def generate_questions(pdf_path: str, user_query: str): +def load_pdf_chunks(pdf_path: str, chunk_size: int, chunk_overlap: int) -> list[Document]: + """Read a PDF page by page and split it into overlapping chunks.""" - loader = PyPDFLoader(pdf_path) - documents = loader.load() + documents = [ + Document( + page_content=page.extract_text() or "", + metadata={"source": pdf_path, "page": number}, + ) + for number, page in enumerate(PdfReader(pdf_path).pages) + ] + + if not any(doc.page_content.strip() for doc in documents): + raise PdfHasNoText( + "This PDF has no selectable text (it may be a scanned image). " + "Try a PDF exported from a document instead." + ) splitter = RecursiveCharacterTextSplitter( - chunk_size=3000, - chunk_overlap=300 + chunk_size=chunk_size, + chunk_overlap=chunk_overlap ) - chunks = splitter.split_documents(documents) + return splitter.split_documents(documents) + + +def generate_questions(pdf_path: str, user_query: str): + + chunks = load_pdf_chunks(pdf_path, chunk_size=3000, chunk_overlap=300) context = "\n\n".join( doc.page_content @@ -62,15 +103,7 @@ def generate_questions(pdf_path: str, user_query: str): def create_pdf_vectorstore(pdf_path: str): - loader = PyPDFLoader(pdf_path) - documents = loader.load() - - splitter = RecursiveCharacterTextSplitter( - chunk_size=1000, - chunk_overlap=150 - ) - - chunks = splitter.split_documents(documents) + chunks = load_pdf_chunks(pdf_path, chunk_size=1000, chunk_overlap=150) vectorstore = InMemoryVectorStore.from_documents( chunks, @@ -144,30 +177,59 @@ def _extract_json(raw: str) -> dict: return json.loads(text[start:end + 1]) -def generate_quiz(pdf_path: str, user_query: str) -> list[dict]: +def build_quiz_prompt( + context: str, + user_query: str, + count: int, + difficulty: str, + exclude: list[str], +) -> str: + """Fill the quiz prompt from the structured generator settings.""" + + if difficulty in DIFFICULTIES: + difficulty_rule = f'Every question must have "difficulty": "{difficulty}".' + else: + difficulty_rule = "Mix Low, Medium and High difficulty." + + if exclude: + listed = "\n".join(f"- {text}" for text in exclude) + exclude_rule = ( + "Do NOT repeat or rephrase any of these existing questions:\n" + f"{listed}" + ) + else: + exclude_rule = "" + + return QUIZ_JSON_PROMPT.format( + context=context, + user_query=user_query.strip() or "Cover the most important concepts.", + count=count, + difficulty_rule=difficulty_rule, + exclude_rule=exclude_rule, + ) + + +def generate_quiz( + pdf_path: str, + user_query: str, + count: int = 5, + difficulty: str = "Mixed", + exclude: list[str] | None = None, +) -> list[dict]: """Generate multiple-choice questions as structured data. - Returns a list of questions, each with exactly four options and + Returns at most `count` questions, each with exactly four options and exactly one correct answer. Malformed questions are dropped rather - than failing the whole request. + than failing the whole request. `difficulty` is one of DIFFICULTIES, + or anything else for a mix; `exclude` lists question texts the model + must not repeat (used to regenerate a single question). """ - loader = PyPDFLoader(pdf_path) - documents = loader.load() - - splitter = RecursiveCharacterTextSplitter( - chunk_size=3000, - chunk_overlap=300 - ) - - chunks = splitter.split_documents(documents) + chunks = load_pdf_chunks(pdf_path, chunk_size=3000, chunk_overlap=300) context = "\n\n".join(doc.page_content for doc in chunks) - prompt = QUIZ_JSON_PROMPT.format( - context=context, - user_query=user_query - ) + prompt = build_quiz_prompt(context, user_query, count, difficulty, exclude or []) response = get_groq_llm().invoke(prompt) @@ -201,7 +263,7 @@ def generate_quiz(pdf_path: str, user_query: str) -> list[dict]: difficulty = str(item.get("difficulty", "Medium")).capitalize() - if difficulty not in ("Low", "Medium", "High"): + if difficulty not in DIFFICULTIES: difficulty = "Medium" questions.append({ @@ -210,4 +272,4 @@ def generate_quiz(pdf_path: str, user_query: str) -> list[dict]: "options": cleaned, }) - return questions + return questions[:count] diff --git a/apps/genAI/package.json b/apps/genAI/package.json index 75ab502..74ff290 100644 --- a/apps/genAI/package.json +++ b/apps/genAI/package.json @@ -4,12 +4,13 @@ "private": true, "description": "Rexial GenAI service (FastAPI) - RAG question generation and chat", "scripts": { - "setup": "python3 -m venv .venv && .venv/bin/pip install --upgrade pip && .venv/bin/pip install -r requirements.txt", + "setup": "python3 -m venv .venv && .venv/bin/pip install --upgrade pip && .venv/bin/pip install -r requirements-dev.txt", "dev": "sh -c '[ -x .venv/bin/uvicorn ] && UV=.venv/bin/uvicorn || UV=uvicorn; exec \"$UV\" app.main:app --host 0.0.0.0 --port \"${GENAI_PORT:-8000}\" --reload'", "start": "sh -c '[ -x .venv/bin/uvicorn ] && UV=.venv/bin/uvicorn || UV=uvicorn; exec \"$UV\" app.main:app --host 0.0.0.0 --port \"${GENAI_PORT:-8000}\"'", "build": "sh -c '[ -x .venv/bin/python ] && PY=.venv/bin/python || PY=python3; exec \"$PY\" -m compileall -q app'", "lint": "sh -c '[ -x .venv/bin/python ] && PY=.venv/bin/python || PY=python3; exec \"$PY\" -m compileall -q app'", - "check-types": "sh -c '[ -x .venv/bin/python ] && PY=.venv/bin/python || PY=python3; exec \"$PY\" -m compileall -q app'" + "check-types": "sh -c '[ -x .venv/bin/python ] && PY=.venv/bin/python || PY=python3; exec \"$PY\" -m compileall -q app'", + "test": "sh -c '[ -x .venv/bin/python ] && PY=.venv/bin/python || PY=python3; exec \"$PY\" -m pytest -q'" }, "engines": { "node": ">=18" diff --git a/apps/genAI/pyproject.toml b/apps/genAI/pyproject.toml index f3ea83a..fb890d9 100644 --- a/apps/genAI/pyproject.toml +++ b/apps/genAI/pyproject.toml @@ -24,3 +24,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # Prompt modules are long strings, not code to wrap. "app/prompts/*.py" = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/apps/genAI/requirements-dev.txt b/apps/genAI/requirements-dev.txt new file mode 100644 index 0000000..b295788 --- /dev/null +++ b/apps/genAI/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +pytest +httpx +ruff diff --git a/apps/genAI/requirements.txt b/apps/genAI/requirements.txt index f067fd6..82c0a7e 100644 --- a/apps/genAI/requirements.txt +++ b/apps/genAI/requirements.txt @@ -2,26 +2,19 @@ fastapi uvicorn[standard] python-multipart -aiofiles python-dotenv # LangChain core -langchain langchain-core -langchain-community langchain-text-splitters -langgraph # Model providers langchain-google-genai langchain-groq -langchain-huggingface langchain-tavily -# Embeddings / vector search -sentence-transformers -faiss-cpu +# Embeddings (ONNX Runtime, CPU only - no torch) +fastembed # PDF parsing pypdf -pymupdf diff --git a/apps/genAI/tests/__init__.py b/apps/genAI/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/genAI/tests/conftest.py b/apps/genAI/tests/conftest.py new file mode 100644 index 0000000..877820d --- /dev/null +++ b/apps/genAI/tests/conftest.py @@ -0,0 +1,141 @@ +import json +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from langchain_core.embeddings import Embeddings + +from app import main +from app.agent import Qnagent +from app.rag import rag_system + + +def make_pdf(*lines: str) -> bytes: + """Build a minimal single-page PDF whose text layer holds `lines`. + + With no lines the page has no text at all, like a scanned document. + """ + + ops = ["BT", "/F1 12 Tf", "72 720 Td"] + + for line in lines: + escaped = line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + ops += [f"({escaped}) Tj", "0 -16 Td"] + + ops.append("ET") + stream = "\n".join(ops).encode() if lines else b"" + + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream", + ] + + out = b"%PDF-1.4\n" + offsets = [] + + for number, body in enumerate(objects, start=1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % number + body + b"\nendobj\n" + + xref = len(out) + out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1) + out += b"".join(b"%010d 00000 n \n" % offset for offset in offsets) + out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % ( + len(objects) + 1, + xref, + ) + + return out + + +SAMPLE_PDF = make_pdf( + "Photosynthesis converts light energy into chemical energy.", + "Mitochondria are the powerhouse of the cell.", +) + + +def question(text: str, difficulty: str = "Medium", correct: int = 0) -> dict: + return { + "text": text, + "difficulty": difficulty, + "options": [ + {"text": f"{text} option {i}", "isCorrect": i == correct} + for i in range(4) + ], + } + + +class FakeLLM: + """Records every prompt and replies with queued responses in order.""" + + def __init__(self): + self.prompts = [] + self.replies = [] + + def reply(self, *contents): + self.replies.extend(contents) + return self + + def invoke(self, prompt): + self.prompts.append(prompt) + + content = self.replies.pop(0) if self.replies else "ok" + + if isinstance(content, Exception): + raise content + + return SimpleNamespace(content=content) + + +class KeywordEmbeddings(Embeddings): + """Deterministic embeddings: one dimension per keyword it contains.""" + + KEYWORDS = ("photosynthesis", "light", "mitochondria", "cell", "energy") + + def _embed(self, text: str) -> list[float]: + lowered = text.lower() + return [1.0 if k in lowered else 0.0 for k in self.KEYWORDS] + [0.01] + + def embed_documents(self, texts): + return [self._embed(t) for t in texts] + + def embed_query(self, text): + return self._embed(text) + + +@pytest.fixture +def groq(monkeypatch): + llm = FakeLLM() + monkeypatch.setattr(rag_system, "get_groq_llm", lambda: llm) + return llm + + +@pytest.fixture +def gemini(monkeypatch): + llm = FakeLLM() + monkeypatch.setattr(Qnagent, "get_google_llm", lambda: llm) + return llm + + +@pytest.fixture +def fake_embeddings(monkeypatch): + monkeypatch.setattr(rag_system, "get_embeddings", KeywordEmbeddings) + + +@pytest.fixture +def upload_dir(tmp_path, monkeypatch): + monkeypatch.setattr(main, "UPLOAD_DIR", tmp_path) + return tmp_path + + +@pytest.fixture +def client(upload_dir): + return TestClient(main.app) + + +def quiz_reply(*questions: dict) -> str: + return json.dumps({"questions": list(questions)}) diff --git a/apps/genAI/tests/test_ask_and_chat.py b/apps/genAI/tests/test_ask_and_chat.py new file mode 100644 index 0000000..a725343 --- /dev/null +++ b/apps/genAI/tests/test_ask_and_chat.py @@ -0,0 +1,66 @@ +from tests.conftest import SAMPLE_PDF, make_pdf + + +def ask(client, query, pdf=SAMPLE_PDF): + return client.post( + "/ask-pdf", + files={"file": ("notes.pdf", pdf, "application/pdf")}, + data={"user_query": query}, + ) + + +def test_health(client): + assert client.get("/health").json() == {"status": "ok"} + + +def test_ask_pdf_answers_from_retrieved_context(client, groq, fake_embeddings, upload_dir): + groq.reply("Light energy becomes chemical energy.") + + res = ask(client, "What does photosynthesis do?") + + assert res.status_code == 200 + body = res.json() + assert body["answer"] == "Light energy becomes chemical energy." + assert body["question"] == "What does photosynthesis do?" + assert "Photosynthesis converts light energy" in groq.prompts[0] + assert list(upload_dir.iterdir()) == [] + + +def test_ask_pdf_without_text_is_422(client, groq, fake_embeddings): + assert ask(client, "anything", pdf=make_pdf()).status_code == 422 + + +def test_chat_answers_directly_when_no_search_needed(client, gemini): + gemini.reply("NO_SEARCH", "Hello there!") + + res = client.post("/chat", json={"user_query": "hi"}) + + assert res.json() == {"response": "Hello there!"} + assert len(gemini.prompts) == 2 + + +def test_chat_uses_web_search_when_router_says_so(client, gemini, monkeypatch): + from app.agent import Qnagent + + class FakeSearch: + def invoke(self, args): + return f"results for {args['query']}" + + monkeypatch.setattr(Qnagent, "get_search", FakeSearch) + gemini.reply("SEARCH", "It is sunny.") + + res = client.post("/chat", json={"user_query": "weather today"}) + + assert res.json() == {"response": "It is sunny."} + assert "results for weather today" in gemini.prompts[1][0].content + + +def test_chat_flattens_gemini_content_parts(client, gemini): + gemini.reply( + [{"type": "text", "text": "NO_"}, "SEARCH"], + [{"type": "text", "text": "Hello, "}, {"type": "text", "text": "world"}], + ) + + res = client.post("/chat", json={"user_query": "hi"}) + + assert res.json() == {"response": "Hello, world"} diff --git a/apps/genAI/tests/test_generate_quiz.py b/apps/genAI/tests/test_generate_quiz.py new file mode 100644 index 0000000..fdb5917 --- /dev/null +++ b/apps/genAI/tests/test_generate_quiz.py @@ -0,0 +1,152 @@ +import json + +from app import main +from tests.conftest import SAMPLE_PDF, make_pdf, question, quiz_reply + + +def post_quiz(client, pdf=SAMPLE_PDF, content_type="application/pdf", **fields): + return client.post( + "/generate-quiz", + files={"file": ("notes.pdf", pdf, content_type)}, + data=fields, + ) + + +def test_returns_valid_questions_and_honours_count(client, groq): + groq.reply(quiz_reply(question("Q1"), question("Q2"), question("Q3"))) + + res = post_quiz(client, count="2") + + assert res.status_code == 200 + body = res.json() + assert body["count"] == 2 + assert [q["text"] for q in body["questions"]] == ["Q1", "Q2"] + + +def test_prompt_carries_settings_and_pdf_text(client, groq): + groq.reply(quiz_reply(question("Q1", "High"))) + + post_quiz(client, count="7", difficulty="High", user_query="chapter 2") + + prompt = groq.prompts[0] + assert "Generate exactly 7 questions." in prompt + assert 'Every question must have "difficulty": "High".' in prompt + assert "chapter 2" in prompt + assert "Photosynthesis converts light energy" in prompt + + +def test_mixed_difficulty_and_empty_focus(client, groq): + groq.reply(quiz_reply(question("Q1"))) + + post_quiz(client, difficulty="Mixed") + + prompt = groq.prompts[0] + assert "Mix Low, Medium and High difficulty." in prompt + assert "Cover the most important concepts." in prompt + assert "Do NOT repeat" not in prompt + + +def test_exclude_lists_existing_questions(client, groq): + groq.reply(quiz_reply(question("Fresh question"))) + + res = post_quiz( + client, + count="1", + exclude=json.dumps(["What is photosynthesis?", "What are mitochondria?"]), + ) + + assert res.status_code == 200 + prompt = groq.prompts[0] + assert "Do NOT repeat or rephrase" in prompt + assert "- What is photosynthesis?" in prompt + assert "- What are mitochondria?" in prompt + + +def test_malformed_questions_are_dropped(client, groq): + two_correct = question("Two correct") + two_correct["options"][1]["isCorrect"] = True + three_options = question("Three options") + three_options["options"].pop() + bad_difficulty = question("Odd difficulty", difficulty="extreme") + + groq.reply(quiz_reply(two_correct, three_options, bad_difficulty)) + + body = post_quiz(client).json() + + assert [q["text"] for q in body["questions"]] == ["Odd difficulty"] + assert body["questions"][0]["difficulty"] == "Medium" + + +def test_json_wrapped_in_markdown_fence_is_accepted(client, groq): + groq.reply("```json\n" + quiz_reply(question("Fenced")) + "\n```") + + assert post_quiz(client).json()["questions"][0]["text"] == "Fenced" + + +def test_unusable_model_output_is_502(client, groq): + groq.reply("Sorry, I cannot help with that.") + + res = post_quiz(client) + + assert res.status_code == 502 + assert "unusable response" in res.json()["detail"] + + +def test_no_valid_questions_is_422(client, groq): + groq.reply(quiz_reply()) + + assert post_quiz(client).status_code == 422 + + +def test_count_out_of_range_is_rejected(client, groq): + assert post_quiz(client, count="0").status_code == 422 + assert post_quiz(client, count="21").status_code == 422 + assert groq.prompts == [] + + +def test_bad_exclude_is_400(client, groq): + assert post_quiz(client, exclude="not json").status_code == 400 + assert post_quiz(client, exclude=json.dumps([1, 2])).status_code == 400 + assert groq.prompts == [] + + +def test_non_pdf_is_rejected(client, groq): + res = post_quiz(client, pdf=b"hello", content_type="text/plain") + + assert res.status_code == 400 + assert groq.prompts == [] + + +def test_oversized_pdf_is_413(client, groq, monkeypatch, upload_dir): + monkeypatch.setattr(main, "MAX_UPLOAD_BYTES", 100) + + res = post_quiz(client) + + assert res.status_code == 413 + assert "too large" in res.json()["detail"] + assert list(upload_dir.iterdir()) == [] + + +def test_pdf_without_text_is_422(client, groq): + res = post_quiz(client, pdf=make_pdf()) + + assert res.status_code == 422 + assert "no selectable text" in res.json()["detail"] + assert groq.prompts == [] + + +def test_upload_is_deleted_after_success_and_failure(client, groq, upload_dir): + groq.reply(quiz_reply(question("Q1")), RuntimeError("provider down")) + + assert post_quiz(client).status_code == 200 + assert post_quiz(client).status_code == 502 + assert list(upload_dir.iterdir()) == [] + + +def test_rate_limit_maps_to_429(client, groq): + class RateLimitError(Exception): + pass + + groq.reply(RateLimitError("slow down")) + + assert post_quiz(client).status_code == 429 diff --git a/apps/http-server/src/controllers/quizController.ts b/apps/http-server/src/controllers/quizController.ts index 52f71bc..6c9fcb5 100644 --- a/apps/http-server/src/controllers/quizController.ts +++ b/apps/http-server/src/controllers/quizController.ts @@ -3,6 +3,8 @@ import { errorResponse } from '../utils/error'; import { prisma } from '@repo/db'; import { generateCode } from '../utils/code'; +const DIFFICULTIES = ['Low', 'Medium', 'High']; + export const createQuizController = async (req: Request, res: Response) => { try { @@ -190,10 +192,12 @@ export const generateAccessCodeController = async (req: Request, res: Response) export const createQuestionController = async (req: Request, res: Response) => { try { const { quizId } = req.params; - const { text, timeLimit, answers } = req.body; + const { text, timeLimit, answers, difficulty = "Medium" } = req.body; if (!text || !timeLimit || !answers) return errorResponse(res, 400, "Text, time limit and answers are required") + if (!DIFFICULTIES.includes(difficulty)) return errorResponse(res, 400, "Difficulty must be Low, Medium or High") + const isOrganizer = await prisma.quizOrganizer.findFirst({ where: { quizId: quizId as string, @@ -216,6 +220,7 @@ export const createQuestionController = async (req: Request, res: Response) => { text, timeLimit: timeLimit || 15, order: totalQuestions + 1, + difficulty, quizId: quizId as string, answers: { create: answers.map((answer: any) => ({ diff --git a/apps/http-server/src/routes/quiz.ts b/apps/http-server/src/routes/quiz.ts index 1f61134..1861fee 100644 --- a/apps/http-server/src/routes/quiz.ts +++ b/apps/http-server/src/routes/quiz.ts @@ -17,11 +17,11 @@ router.get("/:quizId", getQuizByIdController) // generate or regeneate quiz access code -router.post("/:quizId/generate-access-code", generateAccessCodeController) +router.post("/:quizId/generate-access-code", authMiddleware, generateAccessCodeController) // adding questions -router.post("/:quizId/questions", createQuestionController) +router.post("/:quizId/questions", authMiddleware, createQuestionController) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7654aa9..b258608 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -99,7 +99,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=4)"] interval: 15s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index e351fbd..57e29e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,7 +67,7 @@ services: - UPLOAD_DIR=/app/apps/genAI/uploads - ALLOWED_ORIGINS=* healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=4)"] interval: 15s timeout: 5s retries: 5 diff --git a/packages/db/prisma/migrations/20260927090000_question_difficulty/migration.sql b/packages/db/prisma/migrations/20260927090000_question_difficulty/migration.sql new file mode 100644 index 0000000..c806272 --- /dev/null +++ b/packages/db/prisma/migrations/20260927090000_question_difficulty/migration.sql @@ -0,0 +1,5 @@ +-- Difficulty of a question, as set by the host or suggested by the AI +-- generator. Existing questions default to Medium. +CREATE TYPE "Difficulty" AS ENUM ('Low', 'Medium', 'High'); + +ALTER TABLE "Question" ADD COLUMN "difficulty" "Difficulty" NOT NULL DEFAULT 'Medium'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a9780a9..2817693 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -34,6 +34,12 @@ enum SessionStatus { COMPLETED } +enum Difficulty { + Low + Medium + High +} + model User { id String @id @default(uuid()) name String @@ -81,8 +87,9 @@ model QuizOrganizer { model Question { id String @id @default(uuid()) text String - timeLimit Int @default(15) - order Int + timeLimit Int @default(15) + order Int + difficulty Difficulty @default(Medium) quizId String quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade) diff --git a/planning.md b/planning.md index 8f0cc3a..7b1ba22 100644 --- a/planning.md +++ b/planning.md @@ -102,7 +102,7 @@ AI work stays out of the Node servers and can scale on its own. -> ``POST /chat`` general chat, with web search when needed **How it works** --> PDF is chunked, embedded with ``sentence-transformers``, and searched +-> PDF is chunked, embedded with ``fastembed`` (ONNX, CPU), and searched with an in-memory vector store (RAG) -> Groq runs question generation; Gemini runs chat; Tavily handles web search -> Model output is validated before it is returned: any question without diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82d65e0..5c6f708 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,15 @@ importers: '@eslint/js': specifier: ^9.39.4 version: 9.39.4 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.2(@types/node@24.12.0)(jsdom@30.1.1)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1))) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/node': specifier: ^24.12.0 version: 24.12.0 @@ -90,6 +99,9 @@ importers: globals: specifier: ^17.4.0 version: 17.4.0 + jsdom: + specifier: ^30.1.1 + version: 30.1.1 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -99,6 +111,9 @@ importers: vite: specifier: ^8.0.1 version: 8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1) + vitest: + specifier: ^5.0.2 + version: 5.0.2(@types/node@24.12.0)(jsdom@30.1.1)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)) apps/genAI: {} @@ -301,6 +316,17 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@7.1.0': + resolution: {integrity: sha512-s6vMrO4hds+skR2OMmdwSORZiiEbqhAjd56W/0gc/x4PYpTibh/ey5Oy9ywkZ+ycX9EgTcJruhE1T/KkbU7LNA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@9.2.1': + resolution: {integrity: sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -356,6 +382,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -368,6 +398,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/cst-dts-gen@10.5.0': resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} @@ -384,6 +418,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.1': + resolution: {integrity: sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.1 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.4': + resolution: {integrity: sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.1 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.1': + resolution: {integrity: sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@electric-sql/pglite-socket@0.0.20': resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} hasBin: true @@ -619,6 +689,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@hapi/address@5.1.1': resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} engines: {node: '>=14.0.0'} @@ -677,6 +756,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1116,6 +1198,41 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -1161,6 +1278,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/axios@0.14.4': resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==} deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed. @@ -1171,12 +1291,18 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dotenv@8.2.3': resolution: {integrity: sha512-g2FXjlDX/cYuc5CiQvyU/6kkbP1JtmGzh0obW50zD7OKeILVL0NSpPWLXVfqoAGQjom2/SLLx9zHq0KXvD6mbw==} deprecated: This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed. @@ -1406,6 +1532,20 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1434,10 +1574,18 @@ packages: ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1451,6 +1599,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1479,6 +1634,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1516,6 +1675,9 @@ packages: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1590,6 +1752,10 @@ packages: caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1677,12 +1843,23 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1704,6 +1881,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1734,6 +1914,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -1749,6 +1933,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@16.0.3: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} @@ -1792,6 +1982,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -1808,6 +2002,9 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1931,6 +2128,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1939,6 +2139,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2170,6 +2374,10 @@ packages: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -2200,6 +2408,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2287,6 +2499,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2361,6 +2576,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@30.1.1: + resolution: {integrity: sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2532,6 +2756,10 @@ packages: loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2539,9 +2767,16 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -2549,6 +2784,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -2581,6 +2819,10 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -2717,6 +2959,10 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -2747,6 +2993,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2847,6 +3096,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2930,6 +3183,10 @@ packages: resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prisma@7.5.0: resolution: {integrity: sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -3003,6 +3260,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-router-dom@7.13.2: resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==} engines: {node: '>=20.0.0'} @@ -3036,6 +3296,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -3062,6 +3326,10 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3129,6 +3397,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3236,6 +3508,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3263,6 +3538,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -3302,6 +3581,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3309,10 +3592,25 @@ packages: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tldts-core@7.4.15: + resolution: {integrity: sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==} + + tldts@7.4.15: + resolution: {integrity: sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3325,6 +3623,14 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3472,6 +3778,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -3551,11 +3861,72 @@ packages: yaml: optional: true + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + wait-on@9.0.4: resolution: {integrity: sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==} engines: {node: '>=20.0.0'} hasBin: true + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.2: + resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==} + engines: {node: ^22.14.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -3576,6 +3947,11 @@ packages: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3595,6 +3971,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3642,6 +4025,23 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@7.1.0': + dependencies: + '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@9.2.1': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3719,6 +4119,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3742,6 +4144,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@10.5.0': dependencies: '@chevrotain/gast': 10.5.0 @@ -3761,6 +4167,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': dependencies: '@electric-sql/pglite': 0.3.15 @@ -3945,6 +4375,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.16.0': {} + '@hapi/address@5.1.1': dependencies: '@hapi/hoek': 11.0.7 @@ -3992,6 +4424,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -4336,6 +4770,43 @@ snapshots: tailwindcss: 4.2.2 vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1) + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.2(@types/node@24.12.0)(jsdom@30.1.1)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.2(@types/node@24.12.0)(jsdom@30.1.1)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -4367,6 +4838,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/axios@0.14.4': dependencies: axios: 1.14.0 @@ -4382,6 +4855,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.5.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 25.5.0 @@ -4390,6 +4868,8 @@ snapshots: dependencies: '@types/node': 25.5.0 + '@types/deep-eql@4.0.2': {} + '@types/dotenv@8.2.3': dependencies: dotenv: 17.3.1 @@ -4694,6 +5174,17 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1) + '@vitest/mocker@5.0.2(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1) + + '@vitest/spy@5.0.2': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4725,10 +5216,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -4740,6 +5235,12 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -4797,6 +5298,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} asynckit@0.4.0: {} @@ -4828,6 +5331,10 @@ snapshots: node-addon-api: 8.7.0 node-gyp-build: 4.8.4 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} body-parser@2.2.2: @@ -4918,6 +5425,8 @@ snapshots: caniuse-lite@1.0.30001761: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5001,10 +5510,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.1.3: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -5029,6 +5552,8 @@ snapshots: optionalDependencies: supports-color: 5.5.0 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -5053,6 +5578,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -5063,6 +5590,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@16.0.3: {} dotenv@16.6.1: {} @@ -5101,6 +5632,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@8.1.0: {} + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -5181,6 +5714,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5399,10 +5934,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} + expect-type@1.4.0: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -5464,6 +6005,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5660,6 +6205,12 @@ snapshots: hono@4.11.4: {} + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5687,6 +6238,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -5788,6 +6341,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-property@1.0.2: {} @@ -5868,6 +6423,31 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.1.1: + dependencies: + '@asamuzakjp/css-color': 7.1.0 + '@asamuzakjp/dom-selector': 9.2.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.0.1 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.11.2 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.2 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6009,20 +6589,30 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 lru.min@1.1.4: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + make-error@1.3.6: {} math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -6046,6 +6636,8 @@ snapshots: dependencies: mime-db: 1.54.0 + min-indent@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -6188,6 +6780,8 @@ snapshots: obuf@1.1.2: {} + obug@2.2.1: {} + ohash@2.0.11: {} on-finished@2.4.1: @@ -6225,6 +6819,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} passport-google-oauth20@2.0.0: @@ -6316,6 +6914,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.7: {} + pirates@4.0.7: {} pkg-types@1.3.1: @@ -6373,6 +6973,12 @@ snapshots: prettier@3.7.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@prisma/config': 7.5.0 @@ -6450,6 +7056,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -6474,6 +7082,11 @@ snapshots: readdirp@4.1.2: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -6515,6 +7128,8 @@ snapshots: remeda@2.33.4: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -6632,6 +7247,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -6754,6 +7373,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -6805,6 +7426,10 @@ snapshots: strip-bom@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -6841,15 +7466,30 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@6.2.0: {} + tinyexec@0.3.2: {} tinyexec@1.0.4: {} + tinyexec@1.3.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tldts-core@7.4.15: {} + + tldts@7.4.15: + dependencies: + tldts-core: 7.4.15 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6858,6 +7498,14 @@ snapshots: touch@3.1.1: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.15 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-api-utils@2.1.0(typescript@5.9.2): @@ -7042,6 +7690,8 @@ snapshots: undici-types@7.18.2: {} + undici@8.11.2: {} + unpipe@1.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -7079,6 +7729,32 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 + vitest@5.0.2(@types/node@24.12.0)(jsdom@30.1.1)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1) + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 24.12.0 + jsdom: 30.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + wait-on@9.0.4: dependencies: axios: 1.14.0 @@ -7089,6 +7765,26 @@ snapshots: transitivePeerDependencies: - debug + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.2: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -7134,12 +7830,18 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.2: {} + word-wrap@1.2.5: {} wrappy@1.0.2: {} ws@8.20.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {}