diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd3619b2..1d605638 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,11 @@ "axios": "^1.11.0", "bootstrap": "^5.3.7", "file-saver": "^2.0.5", + + "jspdf": "^4.2.1", + "papaparse": "^5.5.3", + + "react": "^18.3.1", "react-bootstrap": "^2.10.10", "react-dom": "^18.3.1", @@ -4606,6 +4611,12 @@ "@types/node": "*" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -4636,6 +4647,13 @@ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -6075,6 +6093,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.30", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", @@ -6439,6 +6467,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", @@ -7079,6 +7127,16 @@ "postcss": "^8.4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -7847,6 +7905,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -9080,6 +9148,17 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -9126,6 +9205,12 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -10204,6 +10289,20 @@ } } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -10516,6 +10615,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -12291,6 +12396,23 @@ "node": ">=0.10.0" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -13875,6 +13997,20 @@ "node": ">=6" } }, + + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -16554,6 +16690,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -17356,6 +17502,16 @@ "node": ">=8" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -17769,6 +17925,16 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "license": "MIT" }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/svgo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", @@ -18190,6 +18356,16 @@ "node": ">=8" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -18892,6 +19068,16 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index de954455..4e4fc76f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,10 @@ "axios": "^1.11.0", "bootstrap": "^5.3.7", "file-saver": "^2.0.5", + + "jspdf": "^4.2.1", + "papaparse": "^5.5.3", + "react": "^18.3.1", "react-bootstrap": "^2.10.10", "react-dom": "^18.3.1", diff --git a/frontend/src/components/ChatPanel/MessageBubble.jsx b/frontend/src/components/ChatPanel/MessageBubble.jsx index f578c751..703dd826 100644 --- a/frontend/src/components/ChatPanel/MessageBubble.jsx +++ b/frontend/src/components/ChatPanel/MessageBubble.jsx @@ -2,9 +2,13 @@ import React from "react"; import ReactMarkdown from "react-markdown"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; + +import { exportQuizToPdf, exportQuizToWord } from "../../utils/quizExporter"; + import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder"; import BookmarkIcon from "@mui/icons-material/Bookmark"; + // Strict allowlist for AI-generated markdown content. // // ReactMarkdown converts markdown to a virtual DOM; rehype-sanitize then @@ -46,6 +50,7 @@ const MODE_BADGE = { socratic: { label: "Socratic", bg: "rgba(139,92,246,0.15)", color: "#8B5CF6" }, eli5: { label: "Simple", bg: "rgba(34,197,94,0.15)", color: "#22C55E" }, concise: { label: "Concise", bg: "rgba(249,115,22,0.15)", color: "#F97316" }, + quiz: { label: "Quiz", bg: "rgba(139,92,246,0.15)", color: "#8B5CF6" }, }; const MessageBubble = ({ @@ -153,6 +158,43 @@ const MessageBubble = ({ {msg.text} )} + + {msg.role === "bot" && !msg.streaming && (msg.mode === "quiz" || msg.text.includes("# Quiz") || msg.text.toLowerCase().includes("quiz:")) && ( +
+ + + {followup && !msg.streaming && (
))}
+
)} diff --git a/frontend/src/utils/quizExporter.js b/frontend/src/utils/quizExporter.js new file mode 100644 index 00000000..a60e830d --- /dev/null +++ b/frontend/src/utils/quizExporter.js @@ -0,0 +1,184 @@ +import { jsPDF } from "jspdf"; +import { saveAs } from "file-saver"; + +/** + * Parses markdown-style quiz text and converts it to HTML for Word export. + */ +const parseMarkdownToHtml = (markdown) => { + return markdown + .split("\n") + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) { + return "
"; + } + + // Headers + if (line.startsWith("# ")) { + return `

${line.substring(2)}

`; + } + if (line.startsWith("## ")) { + return `

${line.substring(3)}

`; + } + + // Question line (e.g., "1. **Question:** What is...") + if (trimmed.match(/^\d+\./)) { + const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "$1"); + return `

${formatted}

`; + } + + // Option line (e.g., "- A) Option text") + if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + const optionText = trimmed.substring(2); + return `

${optionText}

`; + } + + // Correct Answer line + if (trimmed.includes("Correct Answer:")) { + const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "$1"); + return `

${formatted}

`; + } + + // Default line formatting + const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "$1"); + return `

${formatted}

`; + }) + .join("\n"); +}; + +/** + * Generates and downloads a Word Document (.doc) from the markdown quiz text. + */ +export const exportQuizToWord = (quizText, filenamePrefix = "quiz") => { + const parsedHtml = parseMarkdownToHtml(quizText); + + const header = ` + + + + AI Generated Quiz + + + + ${parsedHtml} + + + `.trim(); + + const blob = new Blob([header], { type: "application/msword;charset=utf-8" }); + const filename = `${filenamePrefix.replace(/\s+/g, "_")}_quiz.doc`; + saveAs(blob, filename); +}; + +/** + * Generates and downloads a beautifully styled PDF from the markdown quiz text. + */ +export const exportQuizToPdf = (quizText, filenamePrefix = "quiz") => { + const doc = new jsPDF({ + orientation: "portrait", + unit: "mm", + format: "a4", + }); + + const lines = quizText.split("\n"); + let y = 25; // Vertical cursor + const pageHeight = doc.internal.pageSize.height; + const margin = 20; + const contentWidth = doc.internal.pageSize.width - 2 * margin; + + // Header styling + doc.setFont("Helvetica", "bold"); + doc.setFontSize(22); + doc.setTextColor(139, 92, 246); // Brand purple color + + lines.forEach((line) => { + const trimmed = line.trim(); + if (!trimmed) { + y += 4; + return; + } + + let text = trimmed; + let isTitle = false; + let isQuestion = false; + let isOption = false; + let isAnswer = false; + + if (line.startsWith("# ")) { + isTitle = true; + text = line.substring(2); + } else if (trimmed.match(/^\d+\./)) { + isQuestion = true; + text = trimmed.replace(/\*\*/g, ""); // Remove bold markdown formatting + } else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + isOption = true; + text = " \u2022 " + trimmed.substring(2); // Bullet list format + } else if (trimmed.includes("Correct Answer:")) { + isAnswer = true; + text = " " + trimmed.replace(/\*\*/g, ""); // Indented answer line + } else { + text = trimmed.replace(/\*\*/g, ""); + } + + // Set font style and size based on the type of line + if (isTitle) { + doc.setFont("Helvetica", "bold"); + doc.setFontSize(20); + doc.setTextColor(139, 92, 246); + } else if (isQuestion) { + doc.setFont("Helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(30, 41, 59); // Slate-800 + y += 4; // Extra spacing before a question + } else if (isOption) { + doc.setFont("Helvetica", "normal"); + doc.setFontSize(11); + doc.setTextColor(75, 85, 99); // Gray-600 + } else if (isAnswer) { + doc.setFont("Helvetica", "bold"); + doc.setFontSize(11); + doc.setTextColor(16, 185, 129); // Green-500 + } else { + doc.setFont("Helvetica", "normal"); + doc.setFontSize(11); + doc.setTextColor(31, 41, 55); // Gray-800 + } + + // Wrap text to fit page width + const splitText = doc.splitTextToSize(text, contentWidth); + + splitText.forEach((textLine) => { + // Check for page break + if (y > pageHeight - margin) { + doc.addPage(); + y = 25; // Reset top margin for new page + } + doc.text(textLine, margin, y); + y += isTitle ? 12 : 7; + }); + }); + + const filename = `${filenamePrefix.replace(/\s+/g, "_")}_quiz.pdf`; + doc.save(filename); +}; diff --git a/rag-service/main.py b/rag-service/main.py index d3b9906a..871183dd 100644 --- a/rag-service/main.py +++ b/rag-service/main.py @@ -1671,6 +1671,8 @@ def detect_question_intent(question): normalized_question = question.lower() terms = tokenize_text(normalized_question) + if "quiz" in normalized_question or "test" in normalized_question or "questions" in normalized_question or "question paper" in normalized_question: + return "quiz" if "what is this document about" in normalized_question or "what are these documents about" in normalized_question: return "overview" if "how is" in normalized_question and terms.intersection(RELATIONSHIP_QUERY_TERMS): @@ -1989,6 +1991,8 @@ def synthesize_with_ollama(prompt: str) -> Optional[str]: def build_answer_from_documents(question, documents, intent, source_id_by_key=None): + if intent == "quiz": + return None if not has_grounded_keyword_overlap(question, documents) and intent != "overview": return INSUFFICIENT_CONTEXT_MESSAGE if intent == "relationship": @@ -2831,7 +2835,7 @@ def validate_uploaded_pdf(file_path: str) -> str: return trusted_path -VALID_MODES = {"default", "tutor", "socratic", "eli5", "concise"} +VALID_MODES = {"default", "tutor", "socratic", "eli5", "concise", "quiz"} class Question(BaseModel): question: str = Field(..., min_length=1, description="Question cannot be empty") @@ -3639,6 +3643,44 @@ def ask_question(data: Question): _mark_session_dirty(session_id) return result + + if intent == "quiz" or mode == "quiz": + prompt = ( + "You are an expert educator. Based on the provided document context, generate a quiz with 5 multiple-choice questions. " + "Each question must have 4 options (A, B, C, D) and specify the correct answer. " + "Format the output exactly as follows in clean markdown. Do not include any introductory or concluding text, only the quiz itself:\n\n" + "# Quiz: [Quiz Title]\n\n" + "1. **Question:** [Question text]\n" + " - A) [Option A]\n" + " - B) [Option B]\n" + " - C) [Option C]\n" + " - D) [Option D]\n" + " **Correct Answer:** [Correct Option, e.g., A]\n\n" + "2. ...\n\n" + f"Context:\n{context}\n\n" + f"Question/Request: {question}\n" + "Quiz:" + ) + else: + prompt = ( + "You are a careful assistant answering questions over one or more uploaded PDF documents. " + "Use only the provided context. The context may include excerpts from multiple PDFs. " + "When the question asks for a relationship, comparison, or synthesis, connect the relevant facts across documents. " + "If the context does not contain enough information, say that briefly and do not invent details.\n\n" + + "Reference the provided source numbers naturally whenever the answer is directly supported by the context.\n" + "Cite sources using formats like 'According to Source 1' or 'Source 2 explains that...'\n" + + "You are a helpful AI assistant.\n" + "Give clear, conversational, human-friendly answers.\n" + "Do not return raw PDF text or chunks.\n" + "Summarize properly in readable sentences.\n\n" + + f"Context:\n{context}\n\n" + f"Question: {question}\n" + "Answer:" + ) + followup_instructions = "" if mode in ["tutor", "socratic"]: followup_instructions = ( @@ -3673,6 +3715,7 @@ def ask_question(data: Question): "Answer:" ) + logger.info( "Executing query session_id=%s retrieved_chunks=%s sources=%s", session_id, @@ -3723,7 +3766,7 @@ def ask_question(data: Question): logger.info("Falling back to HuggingFace generate_response session_id=%s", session_id) answer = generate_response( prompt, - max_new_tokens=256 + max_new_tokens=512 if (intent == "quiz" or mode == "quiz") else 256 ) framed = apply_mode_framing(answer, question, mode, docs, context) @@ -3943,6 +3986,41 @@ def _grounded_stream(): # LLM generation path — run in a background thread so we can stream tokens # back to the caller as they are produced rather than waiting for the full # completion before sending anything. + + if intent == "quiz" or mode == "quiz": + prompt = ( + "You are an expert educator. Based on the provided document context, generate a quiz with 5 multiple-choice questions. " + "Each question must have 4 options (A, B, C, D) and specify the correct answer. " + "Format the output exactly as follows in clean markdown. Do not include any introductory or concluding text, only the quiz itself:\n\n" + "# Quiz: [Quiz Title]\n\n" + "1. **Question:** [Question text]\n" + " - A) [Option A]\n" + " - B) [Option B]\n" + " - C) [Option C]\n" + " - D) [Option D]\n" + " **Correct Answer:** [Correct Option, e.g., A]\n\n" + "2. ...\n\n" + f"Context:\n{context}\n\n" + f"Question/Request: {question}\n" + "Quiz:" + ) + else: + prompt = ( + "You are a careful assistant answering questions over one or more uploaded PDF documents. " + "Use only the provided context. The context may include excerpts from multiple PDFs. " + "When the question asks for a relationship, comparison, or synthesis, connect the relevant facts across documents. " + "If the context does not contain enough information, say that briefly and do not invent details.\n\n" + "Reference the provided source numbers naturally whenever the answer is directly supported by the context.\n" + "Cite sources using formats like 'According to Source 1' or 'Source 2 explains that...'\n" + "You are a helpful AI assistant.\n" + "Give clear, conversational, human-friendly answers.\n" + "Do not return raw PDF text or chunks.\n" + "Summarize properly in readable sentences.\n\n" + f"Context:\n{context}\n\n" + f"Question: {question}\n" + "Answer:" + ) + followup_instructions = "" if mode in ["tutor", "socratic"]: followup_instructions = ( @@ -3974,6 +4052,7 @@ def _grounded_stream(): "Answer:" ) + logger.info( "Stream executing query session_id=%s retrieved_chunks=%s", session_id, @@ -3987,6 +4066,14 @@ def _generate_and_stream(): yield err return + + generate_kwargs = { + **encoded, + "max_new_tokens": 512 if (intent == "quiz" or mode == "quiz") else 256, + "do_sample": False, + "pad_token_id": pad_token_id, + "streamer": streamer, + full_answer_parts = [] try: import urllib.request @@ -3997,6 +4084,7 @@ def _generate_and_stream(): "Authorization": f"Bearer {groq_api_key}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + } payload = json.dumps({ "model": "llama-3.1-8b-instant", diff --git a/src/data/users.json b/src/data/users.json index 1f0f237d..0f5ad3ea 100644 --- a/src/data/users.json +++ b/src/data/users.json @@ -1,5 +1,13 @@ [ { + + "email": "testuser-1780934845930@example.com", + "password": "$2b$10$9AAsvzxfwNp9haLU2oKNMuMTeY3icNBzPYcPHTx3Wv4w78ZUVD7Ry" + }, + { + "email": "testuser2-1780934846010@example.com", + "password": "$2b$10$6oXLfmhPAQzfCVLwS5iy/Oqoi5THfbxdKVMjxpWIB/29FwmLnPcTC" + "email": "testuser-1780133113651@example.com", "password": "$2b$10$VlcKirFc6zLReabLg97GeOslLoXzPF5WUigQ/JjPrQi5pMpwBuMMe" }, @@ -14,5 +22,6 @@ { "email": "testuser2-1780133179757@example.com", "password": "$2b$10$Qae0TsUBvMMjZ0Ofog1B6uHp0GmV5hlbL1q6mVgFtUX6/STIJJliO" + } ] \ No newline at end of file diff --git a/validators/schemas.js b/validators/schemas.js index f718edc2..22bbcfc8 100644 --- a/validators/schemas.js +++ b/validators/schemas.js @@ -53,7 +53,11 @@ const questionSchema = z.preprocess( const modeSchema = z.preprocess( (val) => (typeof val === "string" ? val : "default"), + + z.enum(["default", "tutor", "socratic", "eli5", "concise", "quiz"]).default("default") + z.enum(["default", "tutor", "socratic", "eli5", "concise"]).default("default"), + ); const sessionSecretSchema = z.preprocess(