diff --git a/backend/server.py b/backend/server.py index 683c1241..dd518b77 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,561 +1,741 @@ -from flask import Flask, request, jsonify -from flask_cors import CORS -from pprint import pprint -import nltk -import subprocess -import os -import glob +from __future__ import annotations -from sklearn.metrics.pairwise import cosine_similarity -from sklearn.feature_extraction.text import TfidfVectorizer -nltk.download("stopwords") -nltk.download('punkt_tab') -from Generator import main -from Generator.question_filters import make_question_harder -from Generator.llm_generator import LLMQuestionGenerator -import re -import json -import spacy -from transformers import pipeline -from spacy.lang.en.stop_words import STOP_WORDS -from string import punctuation -from heapq import nlargest +import glob +import os import random -import webbrowser -from apiclient import discovery -from httplib2 import Http -from oauth2client import client, file, tools -from mediawikiapi import MediaWikiAPI - -app = Flask(__name__) -CORS(app) -print("Starting Flask App...") - -SERVICE_ACCOUNT_FILE = './service_account_key.json' -SCOPES = ['https://www.googleapis.com/auth/documents.readonly'] - -MCQGen = main.MCQGenerator() -answer = main.AnswerPredictor() -BoolQGen = main.BoolQGenerator() -ShortQGen = main.ShortQGenerator() -qg = main.QuestionGenerator() -docs_service = main.GoogleDocsService(SERVICE_ACCOUNT_FILE, SCOPES) -file_processor = main.FileProcessor() -mediawikiapi = MediaWikiAPI() -qa_model = pipeline("question-answering") -llm_generator = LLMQuestionGenerator() - - -def process_input_text(input_text, use_mediawiki): - if use_mediawiki == 1: - input_text = mediawikiapi.summary(input_text,8) - return input_text - - -@app.route("/get_mcq", methods=["POST"]) -def get_mcq(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions = data.get("max_questions", 4) - input_text = process_input_text(input_text, use_mediawiki) - output = MCQGen.generate_mcq( - {"input_text": input_text, "max_questions": max_questions} - ) - questions = output["questions"] - return jsonify({"output": questions}) - - -@app.route("/get_boolq", methods=["POST"]) -def get_boolq(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions = data.get("max_questions", 4) - input_text = process_input_text(input_text, use_mediawiki) - output = BoolQGen.generate_boolq( - {"input_text": input_text, "max_questions": max_questions} - ) - boolean_questions = output["Boolean_Questions"] - return jsonify({"output": boolean_questions}) - - -@app.route("/get_shortq", methods=["POST"]) -def get_shortq(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions = data.get("max_questions", 4) - input_text = process_input_text(input_text, use_mediawiki) - output = ShortQGen.generate_shortq( - {"input_text": input_text, "max_questions": max_questions} - ) - questions = output["questions"] - return jsonify({"output": questions}) +import re +import subprocess +from typing import Any, Dict, List, Optional +from flask import Flask, current_app, jsonify, request +from flask_cors import CORS +from werkzeug.utils import secure_filename -@app.route("/get_shortq_llm", methods=["POST"]) -def get_shortq_llm(): - try: - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions = data.get("max_questions", 4) - input_text = process_input_text(input_text, use_mediawiki) - questions = llm_generator.generate_short_questions(input_text, max_questions) - return jsonify({"output": questions}) - except Exception as e: - app.logger.exception("Error in /get_shortq_llm: %s", e) - return jsonify({"error": "Internal server error"}), 500 +GOOGLE_DOCS_SCOPES = ["https://www.googleapis.com/auth/documents.readonly"] +GOOGLE_FORMS_SCOPES = "https://www.googleapis.com/auth/forms.body" +GOOGLE_FORMS_DISCOVERY_DOC = "https://forms.googleapis.com/$discovery/rest?version=v1" +DEFAULT_MAX_QUESTIONS = 4 +MAX_QUESTION_LIMIT = 25 +ALLOWED_UPLOAD_EXTENSIONS = {"txt", "pdf", "docx"} -@app.route("/get_mcq_llm", methods=["POST"]) -def get_mcq_llm(): - try: - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions = data.get("max_questions", 4) - input_text = process_input_text(input_text, use_mediawiki) - questions = llm_generator.generate_mcq_questions(input_text, max_questions) - return jsonify({"output": questions}) - except Exception as e: - app.logger.exception("Error in /get_mcq_llm: %s", e) - return jsonify({"error": "Internal server error"}), 500 +class InvalidRequest(Exception): + """Raised when a request payload is malformed.""" -@app.route("/get_boolq_llm", methods=["POST"]) -def get_boolq_llm(): - try: - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions = data.get("max_questions", 4) - input_text = process_input_text(input_text, use_mediawiki) - questions = llm_generator.generate_boolean_questions(input_text, max_questions) - return jsonify({"output": questions}) - except Exception as e: - app.logger.exception("Error in /get_boolq_llm: %s", e) - return jsonify({"error": "Internal server error"}), 500 +class ServiceUnavailable(Exception): + """Raised when an optional integration or model is not available.""" -@app.route("/get_problems_llm", methods=["POST"]) -def get_problems_llm(): - try: - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - mcq_count = data.get("max_questions_mcq", 2) - bool_count = data.get("max_questions_boolq", 2) - short_count = data.get("max_questions_shortq", 2) - input_text = process_input_text(input_text, use_mediawiki) - questions = llm_generator.generate_all_questions(input_text, mcq_count, bool_count, short_count) - return jsonify({"output": questions}) - except Exception as e: - app.logger.exception("Error in /get_problems_llm: %s", e) - return jsonify({"error": "Internal server error"}), 500 - - -@app.route("/get_problems", methods=["POST"]) -def get_problems(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - max_questions_mcq = data.get("max_questions_mcq", 4) - max_questions_boolq = data.get("max_questions_boolq", 4) - max_questions_shortq = data.get("max_questions_shortq", 4) - input_text = process_input_text(input_text, use_mediawiki) - output1 = MCQGen.generate_mcq( - {"input_text": input_text, "max_questions": max_questions_mcq} - ) - output2 = BoolQGen.generate_boolq( - {"input_text": input_text, "max_questions": max_questions_boolq} - ) - output3 = ShortQGen.generate_shortq( - {"input_text": input_text, "max_questions": max_questions_shortq} - ) - return jsonify( - {"output_mcq": output1, "output_boolq": output2, "output_shortq": output3} - ) +class ServiceRegistry: + """Lazily initializes heavyweight services the first time they are used.""" -@app.route("/get_mcq_answer", methods=["POST"]) -def get_mcq_answer(): - data = request.get_json() - input_text = data.get("input_text", "") - input_questions = data.get("input_question", []) - input_options = data.get("input_options", []) - outputs = [] + def __init__(self, service_overrides: Optional[Dict[str, Any]] = None): + self._overrides = service_overrides or {} + self._instances: Dict[str, Any] = {} + self._shared: Dict[str, Any] = {} - if not input_questions or not input_options or len(input_questions) != len(input_options): - return jsonify({"output": outputs}) + def get(self, name: str) -> Any: + if name in self._overrides: + return self._overrides[name] + + if name not in self._instances: + builder = getattr(self, f"_build_{name}", None) + if builder is None: + raise KeyError(f"Unknown service '{name}'") + try: + self._instances[name] = builder() + except (ImportError, FileNotFoundError, OSError) as exc: + raise ServiceUnavailable( + f"Required service '{name}' is unavailable: {exc}" + ) from exc - for question, options in zip(input_questions, input_options): - # Generate answer using the QA model - qa_response = qa_model(question=question, context=input_text) - generated_answer = qa_response["answer"] + return self._instances[name] - # Calculate similarity between generated answer and each option - options_with_answer = options + [generated_answer] - vectorizer = TfidfVectorizer().fit_transform(options_with_answer) - vectors = vectorizer.toarray() - generated_answer_vector = vectors[-1].reshape(1, -1) + def _ensure_nltk(self) -> None: + if self._shared.get("nltk_ready"): + return - similarities = cosine_similarity(vectors[:-1], generated_answer_vector).flatten() - max_similarity_index = similarities.argmax() + import nltk - # Return the option with the highest similarity - best_option = options[max_similarity_index] - - outputs.append(best_option) + nltk.download("stopwords", quiet=True) + nltk.download("punkt_tab", quiet=True) + self._shared["nltk_ready"] = True - return jsonify({"output": outputs}) + def _get_generator_main(self): + if "generator_main" not in self._shared: + self._ensure_nltk() + from Generator import main as generator_main + self._shared["generator_main"] = generator_main + return self._shared["generator_main"] -@app.route("/get_shortq_answer", methods=["POST"]) -def get_answer(): - data = request.get_json() - input_text = data.get("input_text", "") - input_questions = data.get("input_question", []) - answers = [] - for question in input_questions: - qa_response = qa_model(question=question, context=input_text) - answers.append(qa_response["answer"]) + def _build_mcq_generator(self): + return self._get_generator_main().MCQGenerator() - return jsonify({"output": answers}) + def _build_answer_predictor(self): + return self._get_generator_main().AnswerPredictor() + def _build_boolq_generator(self): + return self._get_generator_main().BoolQGenerator() -@app.route("/get_boolean_answer", methods=["POST"]) -def get_boolean_answer(): - data = request.get_json() - input_text = data.get("input_text", "") - input_questions = data.get("input_question", []) - output = [] + def _build_shortq_generator(self): + return self._get_generator_main().ShortQGenerator() - for question in input_questions: - qa_response = answer.predict_boolean_answer( - {"input_text": input_text, "input_question": question} + def _build_question_generator(self): + return self._get_generator_main().QuestionGenerator() + + def _build_google_docs_service(self): + service_account_file = current_app.config["SERVICE_ACCOUNT_FILE"] + if not os.path.exists(service_account_file): + return None + return self._get_generator_main().GoogleDocsService( + service_account_file, GOOGLE_DOCS_SCOPES ) - if(qa_response): - output.append("True") - else: - output.append("False") - return jsonify({"output": output}) + def _build_file_processor(self): + upload_folder = current_app.config["UPLOAD_FOLDER"] + return self._get_generator_main().FileProcessor(upload_folder=upload_folder) + + def _build_mediawiki(self): + from mediawikiapi import MediaWikiAPI + + return MediaWikiAPI() + + def _build_qa_model(self): + from transformers import pipeline + return pipeline("question-answering") + + def _build_llm_generator(self): + from Generator.llm_generator import LLMQuestionGenerator + + return LLMQuestionGenerator() + + def _build_make_question_harder(self): + from Generator.question_filters import make_question_harder + + return make_question_harder + + def _build_forms_service(self): + client_secrets_file = current_app.config["CLIENT_SECRETS_FILE"] + token_file = current_app.config["FORMS_TOKEN_FILE"] + + if not os.path.exists(client_secrets_file): + return None + + from apiclient import discovery + from httplib2 import Http + from oauth2client import client, file, tools + + store = file.Storage(token_file) + creds = store.get() + + if not creds or creds.invalid: + flow = client.flow_from_clientsecrets( + client_secrets_file, GOOGLE_FORMS_SCOPES + ) + creds = tools.run_flow(flow, store) + + return discovery.build( + "forms", + "v1", + http=creds.authorize(Http()), + discoveryServiceUrl=GOOGLE_FORMS_DISCOVERY_DOC, + static_discovery=False, + ) + + +def _get_registry() -> ServiceRegistry: + return current_app.extensions["eduaid_services"] + + +def _get_service(name: str) -> Any: + return _get_registry().get(name) + + +def _get_json_payload() -> Dict[str, Any]: + data = request.get_json(silent=True) + if not isinstance(data, dict): + raise InvalidRequest("Expected a JSON object in the request body.") + return data + + +def _coerce_question_count(value: Any, default: int = DEFAULT_MAX_QUESTIONS) -> int: + if value in (None, ""): + return default -@app.route('/get_content', methods=['POST']) -def get_content(): try: - data = request.get_json() - document_url = data.get('document_url') - if not document_url: - return jsonify({'error': 'Document URL is required'}), 400 + count = int(value) + except (TypeError, ValueError) as exc: + raise InvalidRequest("Question count must be an integer.") from exc - text = docs_service.get_document_content(document_url) - return jsonify(text) - except ValueError as e: - app.logger.exception("ValueError in /get_content: %s", e) - return jsonify({'error': 'Bad request'}), 400 - except Exception as e: - app.logger.exception("Unhandled exception in /get_content: %s", e) - return jsonify({'error': 'Internal server error'}), 500 - - -@app.route("/generate_gform", methods=["POST"]) -def generate_gform(): - data = request.get_json() - qa_pairs = data.get("qa_pairs", "") - question_type = data.get("question_type", "") - SCOPES = "https://www.googleapis.com/auth/forms.body" - DISCOVERY_DOC = "https://forms.googleapis.com/$discovery/rest?version=v1" - - store = file.Storage("token.json") - creds = None - if not creds or creds.invalid: - flow = client.flow_from_clientsecrets("credentials.json", SCOPES) - creds = tools.run_flow(flow, store) - - form_service = discovery.build( - "forms", - "v1", - http=creds.authorize(Http()), - discoveryServiceUrl=DISCOVERY_DOC, - static_discovery=False, - ) - NEW_FORM = { - "info": { - "title": "EduAid form", - } - } - requests_list = [] - - if question_type == "get_shortq": - for index, qapair in enumerate(qa_pairs): - requests = { - "createItem": { - "item": { - "title": qapair["question"], - "questionItem": { - "question": { - "required": True, - "textQuestion": {}, - } - }, - }, - "location": {"index": index}, - } + if count < 1: + raise InvalidRequest("Question count must be at least 1.") + + return min(count, MAX_QUESTION_LIMIT) + + +def _coerce_flag(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _require_text(value: Any, field_name: str = "input_text") -> str: + text = (value or "").strip() + if not text: + raise InvalidRequest(f"'{field_name}' is required.") + return text + + +def _require_list(value: Any, field_name: str) -> List[Any]: + if value is None: + return [] + if not isinstance(value, list): + raise InvalidRequest(f"'{field_name}' must be a list.") + return value + + +def _normalize_mcq_choices(qapair: Dict[str, Any]) -> List[str]: + options = [option for option in qapair.get("options", []) if option] + answer = qapair.get("answer") + + if isinstance(answer, list): + correct_answer = next( + (item.get("answer") for item in answer if item.get("correct")), + None, + ) + distractors = [ + item.get("answer") for item in answer if item.get("answer") and not item.get("correct") + ] + answer = correct_answer + options = distractors or options + + combined: List[str] = [] + for choice in [answer, *options]: + if choice and choice not in combined: + combined.append(choice) + + random.shuffle(combined) + return combined + + +def _infer_form_question_type(question_type: str, qapair: Dict[str, Any]) -> str: + normalized_question_type = (question_type or "").strip().lower() + pair_question_type = (qapair.get("question_type") or "").strip().lower() + + if normalized_question_type == "get_boolq" or "boolean" in pair_question_type: + return "boolean" + + answer = qapair.get("answer") + if ( + normalized_question_type == "get_mcq" + or pair_question_type.startswith("mcq") + or qapair.get("options") + or isinstance(answer, list) + ): + return "mcq" + + return "short" + + +def _build_form_requests(qa_pairs: List[Dict[str, Any]], question_type: str) -> List[Dict[str, Any]]: + requests_list: List[Dict[str, Any]] = [] + + for index, qapair in enumerate(qa_pairs): + question = (qapair.get("question") or "").strip() + if not question: + continue + + form_question_type = _infer_form_question_type(question_type, qapair) + question_config: Dict[str, Any] = {"required": True} + + if form_question_type == "mcq": + choices = _normalize_mcq_choices(qapair) + if not choices: + continue + question_config["choiceQuestion"] = { + "type": "RADIO", + "options": [{"value": choice} for choice in choices], } - requests_list.append(requests) - elif question_type == "get_mcq": - for index, qapair in enumerate(qa_pairs): - # Extract and filter the options - options = qapair.get("options", []) - valid_options = [ - opt for opt in options if opt - ] # Filter out empty or None options - - # Ensure the answer is included in the choices - choices = [qapair["answer"]] + valid_options[ - :3 - ] # Include up to the first 3 options - - # Randomize the order of the choices - random.shuffle(choices) - - # Prepare the request structure - choices_list = [{"value": choice} for choice in choices] - - requests = { - "createItem": { - "item": { - "title": qapair["question"], - "questionItem": { - "question": { - "required": True, - "choiceQuestion": { - "type": "RADIO", - "options": choices_list, - }, - } - }, - }, - "location": {"index": index}, - } + elif form_question_type == "boolean": + question_config["choiceQuestion"] = { + "type": "RADIO", + "options": [{"value": "True"}, {"value": "False"}], } + else: + question_config["textQuestion"] = {} - requests_list.append(requests) - elif question_type == "get_boolq": - for index, qapair in enumerate(qa_pairs): - choices_list = [ - {"value": "True"}, - {"value": "False"}, - ] - requests = { + requests_list.append( + { "createItem": { "item": { - "title": qapair["question"], - "questionItem": { - "question": { - "required": True, - "choiceQuestion": { - "type": "RADIO", - "options": choices_list, - }, - } - }, + "title": question, + "questionItem": {"question": question_config}, }, "location": {"index": index}, } } + ) - requests_list.append(requests) - else: - for index, qapair in enumerate(qa_pairs): - if "options" in qapair and qapair["options"]: - options = qapair["options"] - valid_options = [ - opt for opt in options if opt - ] # Filter out empty or None options - choices = [qapair["answer"]] + valid_options[ - :3 - ] # Include up to the first 3 options - random.shuffle(choices) - choices_list = [{"value": choice} for choice in choices] - question_structure = { - "choiceQuestion": { - "type": "RADIO", - "options": choices_list, - } - } - elif "answer" in qapair: - question_structure = {"textQuestion": {}} - else: - question_structure = { - "choiceQuestion": { - "type": "RADIO", - "options": [ - {"value": "True"}, - {"value": "False"}, - ], - } - } + return requests_list - requests = { - "createItem": { - "item": { - "title": qapair["question"], - "questionItem": { - "question": { - "required": True, - **question_structure, - } - }, - }, - "location": {"index": index}, - } - } - requests_list.append(requests) - NEW_QUESTION = {"requests": requests_list} +def _process_input_text(input_text: str, use_mediawiki: Any) -> str: + text = _require_text(input_text) - result = form_service.forms().create(body=NEW_FORM).execute() - form_service.forms().batchUpdate( - formId=result["formId"], body=NEW_QUESTION - ).execute() + if _coerce_flag(use_mediawiki): + try: + mediawiki = _get_service("mediawiki") + summary = mediawiki.summary(text, 8) + except Exception as exc: + raise InvalidRequest("Unable to fetch MediaWiki content for the supplied topic.") from exc - edit_url = jsonify(result["responderUri"]) - webbrowser.open_new_tab( - "https://docs.google.com/forms/d/" + result["formId"] + "/edit" - ) - return edit_url + text = (summary or "").strip() + if not text: + raise InvalidRequest("MediaWiki did not return any content for the supplied topic.") + return text -@app.route("/get_shortq_hard", methods=["POST"]) -def get_shortq_hard(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - input_text = process_input_text(input_text,use_mediawiki) - input_questions = data.get("input_question", []) - output = qg.generate( - article=input_text, num_questions=input_questions, answer_style="sentences" - ) +def _normalize_problem_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + normalized_items: List[Dict[str, Any]] = [] + for item in items or []: + normalized = dict(item) + if normalized.get("type") == "mcq" and "correct_answer" not in normalized: + normalized["correct_answer"] = normalized.get("answer") + normalized_items.append(normalized) + return normalized_items - for item in output: - item["question"] = make_question_harder(item["question"]) - return jsonify({"output": output}) +def clean_transcript(file_path: str) -> str: + """Extract and clean transcript text from a VTT file.""" + with open(file_path, "r", encoding="utf-8") as file: + lines = file.readlines() + transcript_lines = [] + skip_metadata = True -@app.route("/get_mcq_hard", methods=["POST"]) -def get_mcq_hard(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - input_text = process_input_text(input_text,use_mediawiki) - input_questions = data.get("input_question", []) - output = qg.generate( - article=input_text, num_questions=input_questions, answer_style="multiple_choice" - ) - - for q in output: - q["question"] = make_question_harder(q["question"]) - - return jsonify({"output": output}) - -@app.route("/get_boolq_hard", methods=["POST"]) -def get_boolq_hard(): - data = request.get_json() - input_text = data.get("input_text", "") - use_mediawiki = data.get("use_mediawiki", 0) - input_questions = data.get("input_question", []) - - input_text = process_input_text(input_text, use_mediawiki) - - # Generate questions using the same QG model - generated = qg.generate( - article=input_text, - num_questions=input_questions, - answer_style="true_false" + for raw_line in lines: + line = raw_line.strip() + if not line: + continue + if line.lower().startswith(("kind:", "language:", "webvtt")): + continue + if "-->" in line: + skip_metadata = False + continue + if skip_metadata: + continue + + line = re.sub(r"<[^>]+>", "", line) + transcript_lines.append(line) + + return " ".join(transcript_lines).strip() + + +def create_app(service_overrides: Optional[Dict[str, Any]] = None) -> Flask: + app = Flask(__name__) + CORS(app) + app.config.update( + SERVICE_ACCOUNT_FILE=os.getenv("EDUAID_SERVICE_ACCOUNT_FILE", "./service_account_key.json"), + CLIENT_SECRETS_FILE=os.getenv("EDUAID_CLIENT_SECRETS_FILE", "credentials.json"), + FORMS_TOKEN_FILE=os.getenv("EDUAID_FORMS_TOKEN_FILE", "token.json"), + UPLOAD_FOLDER=os.getenv("EDUAID_UPLOAD_FOLDER", "uploads"), + SUBTITLES_FOLDER=os.getenv("EDUAID_SUBTITLES_FOLDER", "subtitles"), ) + app.extensions["eduaid_services"] = ServiceRegistry(service_overrides) + + @app.errorhandler(InvalidRequest) + def handle_invalid_request(error: InvalidRequest): + return jsonify({"error": str(error)}), 400 + + @app.errorhandler(ServiceUnavailable) + def handle_service_unavailable(error: ServiceUnavailable): + return jsonify({"error": str(error)}), 503 + + @app.route("/health", methods=["GET"]) + def health(): + return jsonify( + { + "status": "ok", + "google_docs_configured": os.path.exists(app.config["SERVICE_ACCOUNT_FILE"]), + "google_forms_configured": os.path.exists(app.config["CLIENT_SECRETS_FILE"]), + } + ) + + @app.route("/get_mcq", methods=["POST"]) + def get_mcq(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + output = _get_service("mcq_generator").generate_mcq( + {"input_text": input_text, "max_questions": max_questions} + ) or {} + return jsonify({"output": output.get("questions", [])}) + + @app.route("/get_boolq", methods=["POST"]) + def get_boolq(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + output = _get_service("boolq_generator").generate_boolq( + {"input_text": input_text, "max_questions": max_questions} + ) or {} + return jsonify({"output": output.get("Boolean_Questions", [])}) + + @app.route("/get_shortq", methods=["POST"]) + def get_shortq(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + output = _get_service("shortq_generator").generate_shortq( + {"input_text": input_text, "max_questions": max_questions} + ) or {} + return jsonify({"output": output.get("questions", [])}) + + @app.route("/get_shortq_llm", methods=["POST"]) + def get_shortq_llm(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + questions = _get_service("llm_generator").generate_short_questions( + input_text, max_questions + ) + return jsonify({"output": questions}) - # Apply transformation to make each question harder - harder_questions = [make_question_harder(q) for q in generated] + @app.route("/get_mcq_llm", methods=["POST"]) + def get_mcq_llm(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + questions = _get_service("llm_generator").generate_mcq_questions( + input_text, max_questions + ) + return jsonify({"output": questions}) - return jsonify({"output": harder_questions}) + @app.route("/get_boolq_llm", methods=["POST"]) + def get_boolq_llm(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + questions = _get_service("llm_generator").generate_boolean_questions( + input_text, max_questions + ) + return jsonify({"output": questions}) -@app.route('/upload', methods=['POST']) -def upload_file(): - if 'file' not in request.files: - return jsonify({"error": "No file part"}), 400 + @app.route("/get_problems_llm", methods=["POST"]) + def get_problems_llm(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + questions = _get_service("llm_generator").generate_all_questions( + input_text, + _coerce_question_count(data.get("max_questions_mcq"), 2), + _coerce_question_count(data.get("max_questions_boolq"), 2), + _coerce_question_count(data.get("max_questions_shortq"), 2), + ) + return jsonify({"output": _normalize_problem_items(questions)}) - file = request.files['file'] + @app.route("/get_problems", methods=["POST"]) + def get_problems(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) - if file.filename == '': - return jsonify({"error": "No selected file"}), 400 + output_mcq = _get_service("mcq_generator").generate_mcq( + { + "input_text": input_text, + "max_questions": _coerce_question_count(data.get("max_questions_mcq")), + } + ) or {} + output_boolq = _get_service("boolq_generator").generate_boolq( + { + "input_text": input_text, + "max_questions": _coerce_question_count(data.get("max_questions_boolq")), + } + ) or {} + output_shortq = _get_service("shortq_generator").generate_shortq( + { + "input_text": input_text, + "max_questions": _coerce_question_count(data.get("max_questions_shortq")), + } + ) or {} - content = file_processor.process_file(file) - - if content: - return jsonify({"content": content}) - else: - return jsonify({"error": "Unsupported file type or error processing file"}), 400 + return jsonify( + { + "output_mcq": output_mcq, + "output_boolq": output_boolq, + "output_shortq": output_shortq, + } + ) -@app.route("/", methods=["GET"]) -def hello(): - return "The server is working fine" + @app.route("/get_mcq_answer", methods=["POST"]) + def get_mcq_answer(): + from sklearn.feature_extraction.text import TfidfVectorizer + from sklearn.metrics.pairwise import cosine_similarity + + data = _get_json_payload() + input_text = _require_text(data.get("input_text", "")) + input_questions = _require_list(data.get("input_question"), "input_question") + input_options = _require_list(data.get("input_options"), "input_options") + + if not input_questions or not input_options or len(input_questions) != len(input_options): + raise InvalidRequest( + "'input_question' and 'input_options' must be non-empty lists of the same length." + ) + + qa_model = _get_service("qa_model") + outputs = [] + + for question, options in zip(input_questions, input_options): + if not options: + outputs.append("") + continue + + qa_response = qa_model(question=question, context=input_text) + generated_answer = qa_response["answer"] + + vectorizer = TfidfVectorizer().fit_transform([*options, generated_answer]) + vectors = vectorizer.toarray() + generated_answer_vector = vectors[-1].reshape(1, -1) + similarities = cosine_similarity( + vectors[:-1], generated_answer_vector + ).flatten() + best_option = options[similarities.argmax()] + outputs.append(best_option) -def clean_transcript(file_path): - """Extracts and cleans transcript from a VTT file.""" - with open(file_path, "r", encoding="utf-8") as file: - lines = file.readlines() + return jsonify({"output": outputs}) - transcript_lines = [] - skip_metadata = True # Skip lines until we reach actual captions + @app.route("/get_shortq_answer", methods=["POST"]) + def get_shortq_answer(): + data = _get_json_payload() + input_text = _require_text(data.get("input_text", "")) + input_questions = _require_list(data.get("input_question"), "input_question") + qa_model = _get_service("qa_model") - for line in lines: - line = line.strip() + answers = [] + for question in input_questions: + qa_response = qa_model(question=question, context=input_text) + answers.append(qa_response["answer"]) - # Skip metadata lines like "Kind: captions" or "Language: en" - if line.lower().startswith(("kind:", "language:", "webvtt")): - continue - - # Detect timestamps like "00:01:23.456 --> 00:01:25.789" - if "-->" in line: - skip_metadata = False # Now real captions start - continue - - if not skip_metadata: - # Remove formatting tags like ... and <00:00:00.000> - line = re.sub(r"<[^>]+>", "", line) - transcript_lines.append(line) + return jsonify({"output": answers}) - return " ".join(transcript_lines).strip() + @app.route("/get_boolean_answer", methods=["POST"]) + def get_boolean_answer(): + data = _get_json_payload() + input_text = _require_text(data.get("input_text", "")) + input_questions = _require_list(data.get("input_question"), "input_question") + + if not input_questions: + raise InvalidRequest("'input_question' must contain at least one question.") + + predictions = _get_service("answer_predictor").predict_boolean_answer( + {"input_text": input_text, "input_question": input_questions} + ) + output = ["True" if prediction else "False" for prediction in predictions] + + return jsonify({"output": output}) + + @app.route("/get_content", methods=["POST"]) + def get_content(): + data = _get_json_payload() + document_url = _require_text(data.get("document_url", ""), "document_url") + docs_service = _get_service("google_docs_service") + + if docs_service is None: + raise ServiceUnavailable( + "Google Docs integration is not configured. Set EDUAID_SERVICE_ACCOUNT_FILE to a valid service account key." + ) + + text = docs_service.get_document_content(document_url) + return jsonify(text) + + @app.route("/generate_gform", methods=["POST"]) + def generate_gform(): + data = _get_json_payload() + qa_pairs = _require_list(data.get("qa_pairs"), "qa_pairs") + question_type = (data.get("question_type") or "").strip() + requests_list = _build_form_requests(qa_pairs, question_type) + + if not requests_list: + raise InvalidRequest("No valid questions were provided to generate a form.") + + form_service = _get_service("forms_service") + if form_service is None: + raise ServiceUnavailable( + "Google Forms integration is not configured. Add the client secrets file configured by EDUAID_CLIENT_SECRETS_FILE." + ) + + result = form_service.forms().create(body={"info": {"title": "EduAid form"}}).execute() + form_service.forms().batchUpdate( + formId=result["formId"], body={"requests": requests_list} + ).execute() + + return jsonify( + { + "form_link": result["responderUri"], + "edit_link": f"https://docs.google.com/forms/d/{result['formId']}/edit", + "form_id": result["formId"], + } + ) -@app.route('/getTranscript', methods=['GET']) -def get_transcript(): - video_id = request.args.get('videoId') - if not video_id: - return jsonify({"error": "No video ID provided"}), 400 + @app.route("/get_shortq_hard", methods=["POST"]) + def get_shortq_hard(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + qg = _get_service("question_generator") + harder = _get_service("make_question_harder") - subprocess.run(["yt-dlp", "--write-auto-sub", "--sub-lang", "en", "--skip-download", - "--sub-format", "vtt", "-o", f"subtitles/{video_id}.vtt", f"https://www.youtube.com/watch?v={video_id}"], - check=True, capture_output=True, text=True) + output = qg.generate(article=input_text, answer_style="sentences")[:max_questions] + for item in output: + item["question"] = harder(item.get("question", "")) - # Find the latest .vtt file in the "subtitles" folder - subtitle_files = glob.glob("subtitles/*.vtt") - if not subtitle_files: - return jsonify({"error": "No subtitles found"}), 404 + return jsonify({"output": output}) - latest_subtitle = max(subtitle_files, key=os.path.getctime) - transcript_text = clean_transcript(latest_subtitle) + @app.route("/get_mcq_hard", methods=["POST"]) + def get_mcq_hard(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + qg = _get_service("question_generator") + harder = _get_service("make_question_harder") + + output = qg.generate(article=input_text, answer_style="multiple_choice")[ + :max_questions + ] + for item in output: + item["question"] = harder(item.get("question", "")) + + return jsonify({"output": output}) + + @app.route("/get_boolq_hard", methods=["POST"]) + def get_boolq_hard(): + data = _get_json_payload() + input_text = _process_input_text( + data.get("input_text", ""), data.get("use_mediawiki", 0) + ) + max_questions = _coerce_question_count(data.get("max_questions")) + harder = _get_service("make_question_harder") + generated = _get_service("boolq_generator").generate_boolq( + {"input_text": input_text, "max_questions": max_questions} + ) or {} + harder_questions = [ + harder(question) + for question in generated.get("Boolean_Questions", [])[:max_questions] + ] + return jsonify({"output": harder_questions}) + + @app.route("/upload", methods=["POST"]) + def upload_file(): + uploaded_file = request.files.get("file") + if uploaded_file is None: + raise InvalidRequest("No file was uploaded.") + + filename = secure_filename(uploaded_file.filename or "") + if not filename: + raise InvalidRequest("No file was selected.") + + extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if extension not in ALLOWED_UPLOAD_EXTENSIONS: + raise InvalidRequest( + "Unsupported file type. Supported extensions are: txt, pdf, docx." + ) + + uploaded_file.filename = filename + content = _get_service("file_processor").process_file(uploaded_file) + if not content: + raise InvalidRequest("No readable content could be extracted from the uploaded file.") + + return jsonify({"content": content}) - # Optional: Clean up the file after reading - os.remove(latest_subtitle) + @app.route("/", methods=["GET"]) + def hello(): + return "The server is working fine" + + @app.route("/getTranscript", methods=["GET"]) + def get_transcript(): + video_id = (request.args.get("videoId") or "").strip() + if not video_id: + raise InvalidRequest("No video ID provided.") + if not re.fullmatch(r"[A-Za-z0-9_-]{6,20}", video_id): + raise InvalidRequest("Invalid video ID.") + + subtitles_folder = current_app.config["SUBTITLES_FOLDER"] + os.makedirs(subtitles_folder, exist_ok=True) + + output_template = os.path.join(subtitles_folder, f"{video_id}.%(ext)s") + youtube_url = f"https://www.youtube.com/watch?v={video_id}" + + try: + subprocess.run( + [ + "yt-dlp", + "--write-auto-sub", + "--sub-lang", + "en", + "--skip-download", + "--sub-format", + "vtt", + "-o", + output_template, + youtube_url, + ], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise ServiceUnavailable( + "yt-dlp is not installed, so transcript fetching is unavailable." + ) from exc + except subprocess.CalledProcessError as exc: + current_app.logger.exception("Failed to download subtitles for %s", video_id) + return jsonify({"error": exc.stderr or "Failed to download subtitles."}), 502 + + subtitle_files = glob.glob(os.path.join(subtitles_folder, f"{video_id}*.vtt")) + if not subtitle_files: + return jsonify({"error": "No subtitles found"}), 404 + + latest_subtitle = max(subtitle_files, key=os.path.getctime) + transcript_text = clean_transcript(latest_subtitle) + + for subtitle_file in subtitle_files: + try: + os.remove(subtitle_file) + except OSError: + current_app.logger.warning("Failed to remove subtitle file %s", subtitle_file) + + return jsonify({"transcript": transcript_text}) + + return app + + +app = create_app() - return jsonify({"transcript": transcript_text}) if __name__ == "__main__": - os.makedirs("subtitles", exist_ok=True) + os.makedirs(app.config["SUBTITLES_FOLDER"], exist_ok=True) app.run() diff --git a/backend/test_server.py b/backend/test_server.py index d4eee527..f20624ab 100644 --- a/backend/test_server.py +++ b/backend/test_server.py @@ -1,203 +1,278 @@ -import requests -import json - -BASE_URL = 'http://localhost:5000' - -# Shared input text for all endpoints -input_text = ''' - Artificial intelligence (AI) is the simulation of human intelligence processes - by machines, especially computer systems. These processes include learning - (the acquisition of information and rules for using the information), reasoning - (using rules to reach approximate or definite conclusions), and self-correction. - - AI applications include speech recognition, natural language processing, - machine vision, expert systems, and robotics. Machine learning, a subset of AI, - focuses on the development of algorithms that can learn from and make predictions - or decisions based on data. - - Deep learning, a technique within machine learning, involves neural networks - with many layers (hence the term "deep"). It has revolutionized AI by enabling - complex pattern recognition and data processing tasks. - - Ethical considerations in AI include issues of bias in algorithms, privacy concerns - with data collection, and the impact of AI on jobs and society as a whole. -''' - -def test_get_mcq(): - endpoint = '/get_mcq' - data = { - 'input_text': input_text, - 'max_questions': 5 - } - response = make_post_request(endpoint, data) - print(f'/get_mcq Response: {response}') - assert 'output' in response - -def test_get_boolq(): - endpoint = '/get_boolq' - data = { - 'input_text': input_text, - 'max_questions': 3 - } - response = make_post_request(endpoint, data) - print(f'/get_boolq Response: {response}') - assert 'output' in response - -def test_get_shortq(): - endpoint = '/get_shortq' - data = { - 'input_text': input_text, - 'max_questions': 4 - } - response = make_post_request(endpoint, data) - print(f'/get_shortq Response: {response}') - assert 'output' in response - - -def test_get_shortq_llm(): - endpoint = '/get_shortq_llm' - data = { - 'input_text': input_text, - 'max_questions': 3 - } - response = make_post_request(endpoint, data) - print(f'/get_shortq_llm Response: {response}') - assert 'output' in response - assert len(response['output']) > 0 - for qa in response['output']: - assert 'question' in qa - assert 'answer' in qa - print(f" Generated {len(response['output'])} questions via Qwen3-0.6B LLM") - - -def test_get_mcq_llm(): - endpoint = '/get_mcq_llm' - data = { - 'input_text': input_text, - 'max_questions': 2 - } - response = make_post_request(endpoint, data) - print(f'/get_mcq_llm Response: {response}') - assert 'output' in response - assert len(response['output']) > 0 - for mcq in response['output']: - assert 'question' in mcq - assert 'options' in mcq - assert 'correct_answer' in mcq - assert len(mcq['options']) == 4 # Should have 4 options A, B, C, D - print(f" Generated {len(response['output'])} MCQ questions via Qwen3-0.6B LLM") - - -def test_get_boolq_llm(): - endpoint = '/get_boolq_llm' - data = { - 'input_text': input_text, - 'max_questions': 2 - } - response = make_post_request(endpoint, data) - print(f'/get_boolq_llm Response: {response}') - assert 'output' in response - assert len(response['output']) > 0 - for bool_q in response['output']: - assert 'question' in bool_q - assert 'answer' in bool_q - assert isinstance(bool_q['answer'], bool) # Should be boolean - print(f" Generated {len(response['output'])} boolean questions via Qwen3-0.6B LLM") - - -def test_get_problems_llm(): - endpoint = '/get_problems_llm' - data = { - 'input_text': input_text, - 'max_questions_mcq': 1, - 'max_questions_boolq': 1, - 'max_questions_shortq': 1 - } - response = make_post_request(endpoint, data) - print(f'/get_problems_llm Response: {response}') - assert 'output' in response - assert len(response['output']) == 3 # Should have 1 of each type - - # Check that we have all three types - types_found = set() - for item in response['output']: - assert 'type' in item - types_found.add(item['type']) - - if item['type'] == 'mcq': - assert 'question' in item and 'options' in item and 'correct_answer' in item - elif item['type'] == 'boolean': - assert 'question' in item and 'answer' in item - elif item['type'] == 'short_answer': - assert 'question' in item and 'answer' in item - - assert types_found == {'mcq', 'boolean', 'short_answer'} - print(f" Generated mixed question set with {len(response['output'])} questions via Qwen3-0.6B LLM") - -def test_get_problems(): - endpoint = '/get_problems' - data = { - 'input_text': input_text, - 'max_questions_mcq': 3, - 'max_questions_boolq': 2, - 'max_questions_shortq': 4 - } - response = make_post_request(endpoint, data) - print(f'/get_problems Response: {response}') - assert 'output_mcq' in response - assert 'output_boolq' in response - assert 'output_shortq' in response - -def test_root(): - endpoint = '/' - response = requests.get(f'{BASE_URL}{endpoint}') - print(f'Root Endpoint Response: {response.text}') - assert response.status_code == 200 - -def test_get_answer(): - endpoint = '/get_answer' - data = { - 'input_text': input_text, - 'input_question': [ - "What is artificial intelligence?", - "What does AI include?", - "What is deep learning?", - "What are the ethical considerations in AI?" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) + +from server import create_app + + +INPUT_TEXT = """ +Artificial intelligence (AI) is the simulation of human intelligence processes +by machines, especially computer systems. These processes include learning, +reasoning, and self-correction. Machine learning is a subset of AI, and deep +learning is a technique within machine learning. +""".strip() + + +class StubMCQGenerator: + def generate_mcq(self, payload): + return { + "questions": [ + { + "question_statement": "What is AI?", + "answer": "Artificial intelligence", + "options": ["Artistic input", "Applied internet", "Analog interface"], + "context": payload["input_text"], + } + ] + } + + +class StubShortQGenerator: + def generate_shortq(self, payload): + return { + "questions": [ + { + "Question": "What is deep learning?", + "Answer": "A technique within machine learning", + "context": payload["input_text"], + } + ] + } + + +class StubBoolQGenerator: + def generate_boolq(self, payload): + count = payload["max_questions"] + return { + "Boolean_Questions": [f"Boolean question {index + 1}?" for index in range(count)], + "Text": payload["input_text"], + } + + +class StubAnswerPredictor: + def predict_boolean_answer(self, payload): + return [True, False][: len(payload["input_question"])] + + +class StubQuestionGenerator: + def generate(self, article, answer_style): + if answer_style == "multiple_choice": + return [ + { + "question": "Original MCQ one?", + "answer": [ + {"answer": "Correct 1", "correct": True}, + {"answer": "Wrong 1", "correct": False}, + ], + }, + { + "question": "Original MCQ two?", + "answer": [ + {"answer": "Correct 2", "correct": True}, + {"answer": "Wrong 2", "correct": False}, + ], + }, + { + "question": "Original MCQ three?", + "answer": [ + {"answer": "Correct 3", "correct": True}, + {"answer": "Wrong 3", "correct": False}, + ], + }, + ] + + return [ + {"question": "Original short one?", "answer": "A1"}, + {"question": "Original short two?", "answer": "A2"}, + {"question": "Original short three?", "answer": "A3"}, ] - } - response = make_post_request(endpoint, data) - print(f'/get_answer Response: {response}') - assert 'output' in response - -def test_get_boolean_answer(): - endpoint = '/get_boolean_answer' - data = { - 'input_text': input_text, - 'input_question': [ - "Artificial intelligence is the simulation of human intelligence.", - "Deep learning does not involve neural networks.", - "AI applications do not include speech recognition." + + +class StubLLMGenerator: + def generate_short_questions(self, input_text, max_questions): + return [{"question": "Short?", "answer": "Yes"}][:max_questions] + + def generate_mcq_questions(self, input_text, max_questions): + return [ + { + "question": "MCQ?", + "options": ["A", "B", "C", "D"], + "correct_answer": "A", + } + ][:max_questions] + + def generate_boolean_questions(self, input_text, max_questions): + return [{"question": "Boolean?", "answer": True}][:max_questions] + + def generate_all_questions(self, input_text, mcq_count, bool_count, short_count): + return [ + {"type": "mcq", "question": "MCQ?", "options": ["A", "B"], "answer": "A"}, + {"type": "boolean", "question": "Boolean?", "answer": True}, + {"type": "short_answer", "question": "Short?", "answer": "Answer"}, ] - } - response = make_post_request(endpoint, data) - print(f'/get_boolean_answer Response: {response}') - assert 'output' in response - -def make_post_request(endpoint, data): - url = f'{BASE_URL}{endpoint}' - headers = {'Content-Type': 'application/json'} - response = requests.post(url, headers=headers, data=json.dumps(data)) - return response.json() - -if __name__ == '__main__': - test_get_shortq_llm() - test_get_mcq_llm() - test_get_boolq_llm() - test_get_problems_llm() - test_get_mcq() - test_get_boolq() - test_get_shortq() - test_get_problems() - test_root() - test_get_answer() - test_get_boolean_answer() + + +class StubQAModel: + def __call__(self, question, context): + return {"answer": "Artificial intelligence"} + + +class StubDocsService: + def get_document_content(self, document_url): + return "Document body" + + +class StubMediaWiki: + def summary(self, topic, sentences): + return f"Summary for {topic}" + + +class StubFileProcessor: + def process_file(self, uploaded_file): + return "Processed file content" + + +class StubFormsExecute: + def __init__(self, payload): + self.payload = payload + + def execute(self): + return self.payload + + +class StubFormsEndpoint: + def create(self, body): + return StubFormsExecute( + {"formId": "form-123", "responderUri": "https://example.com/form-123"} + ) + + def batchUpdate(self, formId, body): + return StubFormsExecute({"updated": True, "formId": formId, "body": body}) + + +class StubFormsService: + def forms(self): + return StubFormsEndpoint() + + +class ServerRouteTests(unittest.TestCase): + def setUp(self): + services = { + "mcq_generator": StubMCQGenerator(), + "shortq_generator": StubShortQGenerator(), + "boolq_generator": StubBoolQGenerator(), + "answer_predictor": StubAnswerPredictor(), + "question_generator": StubQuestionGenerator(), + "llm_generator": StubLLMGenerator(), + "qa_model": StubQAModel(), + "google_docs_service": StubDocsService(), + "mediawiki": StubMediaWiki(), + "file_processor": StubFileProcessor(), + "make_question_harder": lambda question: f"Hard: {question}", + "forms_service": StubFormsService(), + } + self.app = create_app(service_overrides=services) + self.client = self.app.test_client() + + def test_boolean_answers_use_predictor_output(self): + response = self.client.post( + "/get_boolean_answer", + json={ + "input_text": INPUT_TEXT, + "input_question": ["Question one?", "Question two?"], + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()["output"], ["True", "False"]) + + def test_hard_short_questions_respect_max_questions(self): + response = self.client.post( + "/get_shortq_hard", + json={"input_text": INPUT_TEXT, "max_questions": 2}, + ) + + payload = response.get_json() + self.assertEqual(response.status_code, 200) + self.assertEqual(len(payload["output"]), 2) + self.assertEqual(payload["output"][0]["question"], "Hard: Original short one?") + self.assertEqual(payload["output"][1]["question"], "Hard: Original short two?") + + def test_hard_boolean_questions_respect_max_questions(self): + response = self.client.post( + "/get_boolq_hard", + json={"input_text": INPUT_TEXT, "max_questions": 2}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.get_json()["output"], + ["Hard: Boolean question 1?", "Hard: Boolean question 2?"], + ) + + def test_generate_gform_returns_structured_links(self): + response = self.client.post( + "/generate_gform", + json={ + "question_type": "get_mcq", + "qa_pairs": [ + { + "question": "What is AI?", + "answer": "Artificial intelligence", + "options": ["Applied internet", "Analog interface"], + } + ], + }, + ) + + payload = response.get_json() + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["form_id"], "form-123") + self.assertEqual(payload["form_link"], "https://example.com/form-123") + self.assertTrue(payload["edit_link"].endswith("/form-123/edit")) + + def test_get_content_returns_503_when_docs_service_is_missing(self): + app = create_app(service_overrides={"google_docs_service": None}) + client = app.test_client() + + response = client.post( + "/get_content", + json={"document_url": "https://docs.google.com/document/d/example/edit"}, + ) + + self.assertEqual(response.status_code, 503) + self.assertIn("Google Docs integration is not configured", response.get_json()["error"]) + + def test_get_problems_llm_normalizes_mcq_answer_key(self): + response = self.client.post( + "/get_problems_llm", + json={ + "input_text": INPUT_TEXT, + "max_questions_mcq": 1, + "max_questions_boolq": 1, + "max_questions_shortq": 1, + }, + ) + + payload = response.get_json()["output"] + self.assertEqual(response.status_code, 200) + self.assertEqual(payload[0]["type"], "mcq") + self.assertEqual(payload[0]["correct_answer"], "A") + + def test_health_reports_missing_integrations_without_loading_models(self): + app = create_app(service_overrides={}) + client = app.test_client() + + response = client.get("/health") + payload = response.get_json() + + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["status"], "ok") + self.assertIn("google_docs_configured", payload) + self.assertIn("google_forms_configured", payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/eduaid_desktop/config.js b/eduaid_desktop/config.js index 8f86df3b..8ee26edb 100644 --- a/eduaid_desktop/config.js +++ b/eduaid_desktop/config.js @@ -39,7 +39,7 @@ const config = { // Window configuration window: { titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', - icon: path.join(__dirname, 'assets/icons/win/aossie.ico'), + icon: path.join(__dirname, 'assets', 'aossie.ico'), webPreferences: { nodeIntegration: false, contextIsolation: true, diff --git a/eduaid_desktop/package.json b/eduaid_desktop/package.json index da640973..20448557 100644 --- a/eduaid_desktop/package.json +++ b/eduaid_desktop/package.json @@ -8,12 +8,12 @@ "dev": "concurrently \"npm run start:web\" \"wait-on http://localhost:3000 && electron .\"", "start:web": "cd ../eduaid_web && npm start", "build:web": "cd ../eduaid_web && npm run build", - "copy:web": "xcopy /E /I /Y \"..\\eduaid_web\\build\" \"build\"", - "build": "cd ../eduaid_web && npm run build", + "copy:web": "node -e \"const fs=require('fs');fs.rmSync('build',{recursive:true,force:true});fs.cpSync('../eduaid_web/build','build',{recursive:true});\"", + "build": "npm run build:web && npm run copy:web", "build:electron": "npm run build && electron-builder", "build:all": "npm run build && electron-builder --mac --win --linux", "pack": "electron-builder --dir", - "dist": "npm run build:web && npm run copy:web && electron-builder" + "dist": "npm run build && electron-builder" }, "keywords": [ "eduaid", diff --git a/eduaid_web/package-lock.json b/eduaid_web/package-lock.json index 1c20c0ae..adaab09e 100644 --- a/eduaid_web/package-lock.json +++ b/eduaid_web/package-lock.json @@ -11,7 +11,6 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "dotenv": "^16.4.7", "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -22,6 +21,9 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001782", "tailwindcss": "^3.4.9" } }, @@ -635,9 +637,18 @@ } }, "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, "engines": { "node": ">=6.9.0" }, @@ -1896,6 +1907,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5709,6 +5732,18 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -5848,9 +5883,9 @@ "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -5865,11 +5900,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -5976,9 +6013,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001651", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", - "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "funding": [ { "type": "opencollective", @@ -5992,7 +6029,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", @@ -7136,17 +7174,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dotenv-expand": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", @@ -7182,9 +7209,10 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", - "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==" + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "license": "ISC" }, "node_modules/emittery": { "version": "0.8.1", @@ -7434,9 +7462,10 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -12761,9 +12790,10 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" }, "node_modules/normalize-path": { "version": "3.0.0", @@ -13241,9 +13271,10 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -17306,9 +17337,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -17323,9 +17354,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" diff --git a/eduaid_web/package.json b/eduaid_web/package.json index bbef7002..02b827d1 100644 --- a/eduaid_web/package.json +++ b/eduaid_web/package.json @@ -7,7 +7,6 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "dotenv": "^16.4.7", "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -42,6 +41,9 @@ ] }, "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001782", "tailwindcss": "^3.4.9" } } diff --git a/eduaid_web/src/App.js b/eduaid_web/src/App.js index e9eef0a0..6e3abbb4 100644 --- a/eduaid_web/src/App.js +++ b/eduaid_web/src/App.js @@ -1,8 +1,8 @@ import "./App.css"; import { Routes, Route, HashRouter } from "react-router-dom"; import Home from "./pages/Home"; -import Question_Type from "./pages/Question_Type"; -import Text_Input from "./pages/Text_Input"; +import QuestionType from "./pages/Question_Type"; +import TextInput from "./pages/Text_Input"; import Output from "./pages/Output"; import Previous from "./pages/Previous"; import NotFound from "./pages/PageNotFound"; @@ -12,8 +12,8 @@ function App() { } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/eduaid_web/src/pages/Output.jsx b/eduaid_web/src/pages/Output.jsx index 03b4cbc8..b7541cc3 100644 --- a/eduaid_web/src/pages/Output.jsx +++ b/eduaid_web/src/pages/Output.jsx @@ -7,10 +7,7 @@ import { FiShuffle, FiEdit2, FiCheck, FiX } from "react-icons/fi"; const Output = () => { const [qaPairs, setQaPairs] = useState([]); - const [questionType, setQuestionType] = useState( - localStorage.getItem("selectedQuestionType") - ); - const [pdfMode, setPdfMode] = useState("questions"); + const questionType = localStorage.getItem("selectedQuestionType"); const [editingIndex, setEditingIndex] = useState(null); const [editedQuestion, setEditedQuestion] = useState(""); const [editedAnswer, setEditedAnswer] = useState(""); @@ -90,6 +87,26 @@ const Output = () => { setEditedOptions(updatedOptions); }; + const normalizeMcqPair = (qaPair) => { + const answerList = Array.isArray(qaPair.answer) ? qaPair.answer : null; + const correctAnswer = answerList + ? answerList.find((item) => item.correct)?.answer + : qaPair.answer; + const options = answerList + ? answerList + .filter((item) => !item.correct) + .map((item) => item.answer) + : qaPair.options || []; + + return { + question: qaPair.question || qaPair.question_statement, + question_type: answerList ? "MCQ_Hard" : "MCQ", + options, + answer: correctAnswer, + context: qaPair.context, + }; + }; + useEffect(() => { const qaPairsFromStorage = JSON.parse(localStorage.getItem("qaPairs")) || {}; @@ -110,29 +127,28 @@ const Output = () => { if (qaPairsFromStorage["output_mcq"]) { qaPairsFromStorage["output_mcq"]["questions"].forEach((qaPair) => { + combinedQaPairs.push(normalizeMcqPair(qaPair)); + }); + } + + if (qaPairsFromStorage["output_shortq"]) { + qaPairsFromStorage["output_shortq"]["questions"].forEach((qaPair) => { combinedQaPairs.push({ - question: qaPair.question_statement, - question_type: "MCQ", - options: qaPair.options, - answer: qaPair.answer, + question: qaPair.Question, + question_type: "Short", + answer: qaPair.Answer, context: qaPair.context, }); }); } - if (qaPairsFromStorage["output_mcq"] || questionType === "get_mcq") { + if (questionType === "get_mcq" && qaPairsFromStorage["output"]) { qaPairsFromStorage["output"].forEach((qaPair) => { - combinedQaPairs.push({ - question: qaPair.question_statement, - question_type: "MCQ", - options: qaPair.options, - answer: qaPair.answer, - context: qaPair.context, - }); + combinedQaPairs.push(normalizeMcqPair(qaPair)); }); } - if (questionType == "get_boolq") { + if (questionType === "get_boolq") { qaPairsFromStorage["output"].forEach((qaPair) => { combinedQaPairs.push({ question: qaPair, @@ -154,7 +170,7 @@ const Output = () => { setQaPairs(combinedQaPairs); } - }, []); + }, [questionType]); const generateGoogleForm = async () => { try { @@ -162,7 +178,7 @@ const Output = () => { qa_pairs: qaPairs, question_type: questionType, }); - const formUrl = result.form_link; + const formUrl = result.edit_link || result.form_link; window.open(formUrl, "_blank"); } catch (error) { console.error("Failed to generate Google Form:", error); @@ -180,7 +196,7 @@ const Output = () => { } }; - const generatePDF = async (mode) => { + const generatePDF = async (mode) => { const logoBytes = await loadLogoAsBytes(); const worker = new Worker(new URL("../workers/pdfWorker.js", import.meta.url), { type: "module" }); diff --git a/eduaid_web/src/pages/PageNotFound.jsx b/eduaid_web/src/pages/PageNotFound.jsx index 2c5671f2..321a52cb 100644 --- a/eduaid_web/src/pages/PageNotFound.jsx +++ b/eduaid_web/src/pages/PageNotFound.jsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import "../index.css"; + const NotFound = () => { - const router = useNavigate() + const navigate = useNavigate(); const [countdown, setCountdown] = useState(5); useEffect(() => { @@ -11,14 +12,14 @@ const NotFound = () => { }, 1000); const redirect = setTimeout(() => { - router('/') + navigate('/'); }, 5000); return () => { clearInterval(timer); clearTimeout(redirect); }; - }, []); + }, [navigate]); return (
@@ -36,4 +37,4 @@ const NotFound = () => { ); }; -export default NotFound; \ No newline at end of file +export default NotFound; diff --git a/eduaid_web/src/pages/Text_Input.jsx b/eduaid_web/src/pages/Text_Input.jsx index e341d331..baa78f00 100644 --- a/eduaid_web/src/pages/Text_Input.jsx +++ b/eduaid_web/src/pages/Text_Input.jsx @@ -15,7 +15,6 @@ const Text_Input = () => { const [numQuestions, setNumQuestions] = useState(10); const [loading, setLoading] = useState(false); const fileInputRef = useRef(null); - const [fileContent, setFileContent] = useState(""); const [docUrl, setDocUrl] = useState(""); const [isToggleOn, setIsToggleOn] = useState(0); @@ -73,6 +72,8 @@ const Text_Input = () => { difficulty, localStorage.getItem("selectedQuestionType") ); + } else { + setLoading(false); } }; @@ -85,7 +86,7 @@ const Text_Input = () => { }; const decrementQuestions = () => { - setNumQuestions((prev) => (prev > 0 ? prev - 1 : 0)); + setNumQuestions((prev) => (prev > 1 ? prev - 1 : 1)); }; const getEndpoint = (difficulty, questionType) => { @@ -94,6 +95,8 @@ const Text_Input = () => { return "get_shortq_hard"; } else if (questionType === "get_mcq") { return "get_mcq_hard"; + } else if (questionType === "get_boolq") { + return "get_boolq_hard"; } } return questionType; @@ -185,7 +188,7 @@ const Text_Input = () => { {/* File Upload Section */}
cloud -

Choose a file (PDF, MP3 supported)

+

Choose a file (PDF, DOCX, TXT supported)