From 64cc207fe8f57cb0c58fa443ec65567ef4f160ac Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 28 May 2024 19:38:27 +0100 Subject: [PATCH 01/44] Add comment for each criteria in speaking grading. --- app.py | 108 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 18 deletions(-) diff --git a/app.py b/app.py index 684a422..d257497 100644 --- a/app.py +++ b/app.py @@ -397,10 +397,10 @@ def get_writing_task_2_general_question(): { "role": "user", "content": ( - 'Craft a comprehensive question of ' + difficulty + 'difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate ' - 'to delve into an in-depth analysis of contrasting perspectives on the topic of "' + topic + '". ' - 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints, provide evidence or ' - 'examples, and present a well-rounded argument before concluding with their personal opinion on the subject.') + 'Craft a comprehensive question of ' + difficulty + 'difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate ' + 'to delve into an in-depth analysis of contrasting perspectives on the topic of "' + topic + '". ' + 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints, provide evidence or ' + 'examples, and present a well-rounded argument before concluding with their personal opinion on the subject.') } ] token_count = count_total_tokens(messages) @@ -435,15 +435,35 @@ def grade_speaking_task_1(): answer = speech_to_text(sound_file_name) logging.info("POST - speaking_task_1 - " + str(request_id) + " - Transcripted answer: " + answer) + json_format = { + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": { + "grade": 0.0, + "comment": "extensive comment about fluency and coherence" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "extensive comment about lexical resource" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "extensive comment about grammatical range and accuracy" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "extensive comment about pronunciation on the transcribed answer" + } + } + } + if has_x_words(answer, 20): messages = [ { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' - '{"comment": "comment about answer quality", "overall": 0.0, ' - '"task_response": {"Fluency and Coherence": 0.0, "Lexical Resource": 0.0, ' - '"Grammatical Range and Accuracy": 0.0, "Pronunciation": 0.0}}') + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", @@ -453,6 +473,10 @@ def grade_speaking_task_1(): 'assign a score of 0 if the response fails to address the question. Additionally, provide ' 'detailed commentary highlighting both strengths and weaknesses in the response.' '\n Question: "' + question + '" \n Answer: "' + answer + '"') + }, + { + "role": "user", + "content": 'Address the student as "you"' } ] token_count = count_total_tokens(messages) @@ -579,15 +603,35 @@ def grade_speaking_task_2(): answer = speech_to_text(sound_file_name) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Transcripted answer: " + answer) + json_format = { + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": { + "grade": 0.0, + "comment": "extensive comment about fluency and coherence" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "extensive comment about lexical resource" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "extensive comment about grammatical range and accuracy" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "extensive comment about pronunciation on the transcribed answer" + } + } + } + if has_x_words(answer, 20): messages = [ { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' - '{"comment": "comment about answer quality", "overall": 0.0, ' - '"task_response": {"Fluency and Coherence": 0.0, "Lexical Resource": 0.0, ' - '"Grammatical Range and Accuracy": 0.0, "Pronunciation": 0.0}}') + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", @@ -597,13 +641,17 @@ def grade_speaking_task_2(): 'assign a score of 0 if the response fails to address the question. Additionally, provide ' 'detailed commentary highlighting both strengths and weaknesses in the response.' '\n Question: "' + question + '" \n Answer: "' + answer + '"') + }, + { + "role": "user", + "content": 'Address the student as "you"' } ] token_count = count_total_tokens(messages) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Requesting grading of the answer.") - response = make_openai_call(GPT_3_5_TURBO, messages, token_count,["comment"], - GRADING_TEMPERATURE) + response = make_openai_call(GPT_3_5_TURBO, messages, token_count, ["comment"], + GRADING_TEMPERATURE) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Answer graded: " + str(response)) perfect_answer_messages = [ @@ -800,15 +848,34 @@ def grade_speaking_task_3(): token_count, ["answer"], GEN_QUESTION_TEMPERATURE)) + json_format = { + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": { + "grade": 0.0, + "comment": "extensive comment about fluency and coherence" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "extensive comment about lexical resource" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "extensive comment about grammatical range and accuracy" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "extensive comment about pronunciation on the transcribed answer" + } + } + } messages = [ { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' - '{"comment": "comment about answer quality", "overall": 0.0, ' - '"task_response": {"Fluency and Coherence": 0.0, "Lexical Resource": 0.0, ' - '"Grammatical Range and Accuracy": 0.0, "Pronunciation": 0.0}}') + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) } ] message = ( @@ -833,6 +900,11 @@ def grade_speaking_task_3(): "content": message }) + messages.append({ + "role": "user", + "content": 'Address the student as "you"' + }) + token_count = count_total_tokens(messages) logging.info("POST - speaking_task_3 - " + str(request_id) + " - Requesting grading of the answers.") From 32ac2149f56f57bdacabfcec59b116ce462b8de1 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 28 May 2024 19:49:26 +0100 Subject: [PATCH 02/44] Improve comments for each criteria in speaking grading. --- app.py | 88 +++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/app.py b/app.py index d257497..2fe2384 100644 --- a/app.py +++ b/app.py @@ -441,19 +441,19 @@ def grade_speaking_task_1(): "task_response": { "Fluency and Coherence": { "grade": 0.0, - "comment": "extensive comment about fluency and coherence" + "comment": "extensive comment about fluency and coherence, use examples to justify the grade awarded." }, "Lexical Resource": { "grade": 0.0, - "comment": "extensive comment about lexical resource" + "comment": "extensive comment about lexical resource, use examples to justify the grade awarded." }, "Grammatical Range and Accuracy": { "grade": 0.0, - "comment": "extensive comment about grammatical range and accuracy" + "comment": "extensive comment about grammatical range and accuracy, use examples to justify the grade awarded." }, "Pronunciation": { "grade": 0.0, - "comment": "extensive comment about pronunciation on the transcribed answer" + "comment": "extensive comment about pronunciation on the transcribed answer, use examples to justify the grade awarded." } } } @@ -531,10 +531,22 @@ def grade_speaking_task_1(): "comment": "The audio recorded does not contain enough english words to be graded.", "overall": 0, "task_response": { - "Fluency and Coherence": 0, - "Lexical Resource": 0, - "Grammatical Range and Accuracy": 0, - "Pronunciation": 0 + "Fluency and Coherence": { + "grade": 0.0, + "comment": "" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "" + } } } except Exception as e: @@ -609,19 +621,19 @@ def grade_speaking_task_2(): "task_response": { "Fluency and Coherence": { "grade": 0.0, - "comment": "extensive comment about fluency and coherence" + "comment": "extensive comment about fluency and coherence, use examples to justify the grade awarded." }, "Lexical Resource": { "grade": 0.0, - "comment": "extensive comment about lexical resource" + "comment": "extensive comment about lexical resource, use examples to justify the grade awarded." }, "Grammatical Range and Accuracy": { "grade": 0.0, - "comment": "extensive comment about grammatical range and accuracy" + "comment": "extensive comment about grammatical range and accuracy, use examples to justify the grade awarded." }, "Pronunciation": { "grade": 0.0, - "comment": "extensive comment about pronunciation on the transcribed answer" + "comment": "extensive comment about pronunciation on the transcribed answer, use examples to justify the grade awarded." } } } @@ -631,7 +643,7 @@ def grade_speaking_task_2(): { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", @@ -699,10 +711,22 @@ def grade_speaking_task_2(): "comment": "The audio recorded does not contain enough english words to be graded.", "overall": 0, "task_response": { - "Fluency and Coherence": 0, - "Lexical Resource": 0, - "Grammatical Range and Accuracy": 0, - "Pronunciation": 0 + "Fluency and Coherence": { + "grade": 0.0, + "comment": "" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "" + } } } except Exception as e: @@ -820,10 +844,22 @@ def grade_speaking_task_3(): "comment": "The audio recorded does not contain enough english words to be graded.", "overall": 0, "task_response": { - "Fluency and Coherence": 0, - "Lexical Resource": 0, - "Grammatical Range and Accuracy": 0, - "Pronunciation": 0 + "Fluency and Coherence": { + "grade": 0.0, + "comment": "" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "" + } } } @@ -854,19 +890,19 @@ def grade_speaking_task_3(): "task_response": { "Fluency and Coherence": { "grade": 0.0, - "comment": "extensive comment about fluency and coherence" + "comment": "extensive comment about fluency and coherence, use examples to justify the grade awarded." }, "Lexical Resource": { "grade": 0.0, - "comment": "extensive comment about lexical resource" + "comment": "extensive comment about lexical resource, use examples to justify the grade awarded." }, "Grammatical Range and Accuracy": { "grade": 0.0, - "comment": "extensive comment about grammatical range and accuracy" + "comment": "extensive comment about grammatical range and accuracy, use examples to justify the grade awarded." }, "Pronunciation": { "grade": 0.0, - "comment": "extensive comment about pronunciation on the transcribed answer" + "comment": "extensive comment about pronunciation on the transcribed answer, use examples to justify the grade awarded." } } } @@ -875,7 +911,7 @@ def grade_speaking_task_3(): { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) } ] message = ( From 3f749f1ff5547b98f8dedb56616a28e9fa5e1cff Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 31 May 2024 22:57:35 +0100 Subject: [PATCH 03/44] Update speaking 1 to be like interactive with 5 questions and 2 topics. --- app.py | 366 ++++++++++++++++++++--------------- helper/heygen_api.py | 35 ++-- helper/question_templates.py | 11 +- 3 files changed, 236 insertions(+), 176 deletions(-) diff --git a/app.py b/app.py index 2fe2384..c3cd735 100644 --- a/app.py +++ b/app.py @@ -419,72 +419,56 @@ def get_writing_task_2_general_question(): def grade_speaking_task_1(): request_id = uuid.uuid4() delete_files_older_than_one_day(AUDIO_FILES_PATH) - sound_file_name = AUDIO_FILES_PATH + str(uuid.uuid4()) logging.info("POST - speaking_task_1 - Received request to grade speaking task 1. " "Use this id to track the logs: " + str(request_id) + " - Request data: " + str(request.get_json())) try: data = request.get_json() - question = data.get('question') - answer_firebase_path = data.get('answer') - - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Downloading file " + answer_firebase_path) - download_firebase_file(FIREBASE_BUCKET, answer_firebase_path, sound_file_name) + answers = data.get('answers') + text_answers = [] + perfect_answers = [] logging.info("POST - speaking_task_1 - " + str( - request_id) + " - Downloaded file " + answer_firebase_path + " to " + sound_file_name) + request_id) + " - Received " + str(len(answers)) + " total answers.") - answer = speech_to_text(sound_file_name) - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Transcripted answer: " + answer) + for item in answers: + sound_file_name = AUDIO_FILES_PATH + str(uuid.uuid4()) - json_format = { - "comment": "extensive comment about answer quality", - "overall": 0.0, - "task_response": { - "Fluency and Coherence": { - "grade": 0.0, - "comment": "extensive comment about fluency and coherence, use examples to justify the grade awarded." - }, - "Lexical Resource": { - "grade": 0.0, - "comment": "extensive comment about lexical resource, use examples to justify the grade awarded." - }, - "Grammatical Range and Accuracy": { - "grade": 0.0, - "comment": "extensive comment about grammatical range and accuracy, use examples to justify the grade awarded." - }, - "Pronunciation": { - "grade": 0.0, - "comment": "extensive comment about pronunciation on the transcribed answer, use examples to justify the grade awarded." + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Downloading file " + item["answer"]) + download_firebase_file(FIREBASE_BUCKET, item["answer"], sound_file_name) + logging.info("POST - speaking_task_1 - " + str( + request_id) + " - Downloaded file " + item["answer"] + " to " + sound_file_name) + + answer_text = speech_to_text(sound_file_name) + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Transcripted answer: " + answer_text) + + text_answers.append(answer_text) + item["answer"] = answer_text + os.remove(sound_file_name) + + if not has_x_words(answer_text, 20): + logging.info("POST - speaking_task_1 - " + str( + request_id) + " - The answer had less words than threshold 20 to be graded. Answer: " + answer_text) + return { + "comment": "The audio recorded does not contain enough english words to be graded.", + "overall": 0, + "task_response": { + "Fluency and Coherence": { + "grade": 0.0, + "comment": "" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "" + } + } } - } - } - - if has_x_words(answer, 20): - messages = [ - { - "role": "system", - "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) - }, - { - "role": "user", - "content": ( - 'Evaluate the given Speaking Part 1 response based on the IELTS grading system, ensuring a ' - 'strict assessment that penalizes errors. Deduct points for deviations from the task, and ' - 'assign a score of 0 if the response fails to address the question. Additionally, provide ' - 'detailed commentary highlighting both strengths and weaknesses in the response.' - '\n Question: "' + question + '" \n Answer: "' + answer + '"') - }, - { - "role": "user", - "content": 'Address the student as "you"' - } - ] - token_count = count_total_tokens(messages) - - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Requesting grading of the answer.") - response = make_openai_call(GPT_3_5_TURBO, messages, token_count, ["comment"], - GRADING_TEMPERATURE) - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Answer graded: " + str(response)) perfect_answer_messages = [ { @@ -496,61 +480,111 @@ def grade_speaking_task_1(): "role": "user", "content": ( 'Provide a perfect answer according to ielts grading system to the following ' - 'Speaking Part 1 question: "' + question + '"') + 'Speaking Part 1 question: "' + item["question"] + '"') + }, + { + "role": "user", + "content": 'The answer must be 2 or 3 sentences long.' } ] + token_count = count_total_tokens(perfect_answer_messages) - - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Requesting perfect answer.") - response['perfect_answer'] = make_openai_call(GPT_3_5_TURBO, - perfect_answer_messages, - token_count, - ["answer"], - GEN_QUESTION_TEMPERATURE)["answer"] logging.info("POST - speaking_task_1 - " + str( - request_id) + " - Perfect answer: " + response['perfect_answer']) + request_id) + " - Requesting perfect answer for question: " + item["question"]) + perfect_answers.append(make_openai_call(GPT_4_O, + perfect_answer_messages, + token_count, + ["answer"], + GEN_QUESTION_TEMPERATURE)) - response['transcript'] = answer - - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Requesting fixed text.") - response['fixed_text'] = get_speaking_corrections(answer) - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Fixed text: " + response['fixed_text']) - - if response["overall"] == "0.0" or response["overall"] == 0.0: - response["overall"] = round((response["task_response"]["Fluency and Coherence"] + - response["task_response"]["Lexical Resource"] + response["task_response"][ - "Grammatical Range and Accuracy"] + response["task_response"][ - "Pronunciation"]) / 4, 1) - - logging.info("POST - speaking_task_1 - " + str(request_id) + " - Final response: " + str(response)) - return response - else: - logging.info("POST - speaking_task_1 - " + str( - request_id) + " - The answer had less words than threshold 20 to be graded. Answer: " + answer) - return { - "comment": "The audio recorded does not contain enough english words to be graded.", - "overall": 0, - "task_response": { - "Fluency and Coherence": { - "grade": 0.0, - "comment": "" - }, - "Lexical Resource": { - "grade": 0.0, - "comment": "" - }, - "Grammatical Range and Accuracy": { - "grade": 0.0, - "comment": "" - }, - "Pronunciation": { - "grade": 0.0, - "comment": "" - } + json_format = { + "comment": "comment about answers quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": { + "grade": 0.0, + "comment": "comment about fluency and coherence" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "comment about lexical resource" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "comment about grammatical range and accuracy" + }, + "Pronunciation": { + "grade": 0.0, + "comment": "comment about pronunciation on the transcribed answers" } } + } + + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Formatting answers and questions for prompt.") + formatted_text = "" + for i, entry in enumerate(answers, start=1): + formatted_text += f"**Question {i}:**\n{entry['question']}\n\n" + formatted_text += f"**Answer {i}:**\n{entry['answer']}\n\n" + logging.info("POST - speaking_task_1 - " + str( + request_id) + " - Formatted answers and questions for prompt: " + formatted_text) + + grade_message = ( + 'Evaluate the given Speaking Part 1 response based on the IELTS grading system, ensuring a ' + 'strict assessment that penalizes errors. Deduct points for deviations from the task, and ' + 'assign a score of 0 if the response fails to address the question. Additionally, provide ' + 'detailed commentary highlighting both strengths and weaknesses in the response.' + "\n\n The questions and answers are: \n\n'" + formatted_text) + + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + }, + { + "role": "user", + "content": grade_message + }, + { + "role": "user", + "content": 'Address the student as "you". If the answers are not 2 or 3 sentences long, warn the ' + 'student that they should be.' + }, + { + "role": "user", + "content": 'For pronunciations act as if you heard the answers and they were transcripted as you heard them.' + }, + { + "role": "user", + "content": 'The comments must be long, detailed, justify the grading and suggest improvements.' + } + ] + token_count = count_total_tokens(messages) + + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Requesting grading of the answer.") + response = make_openai_call(GPT_4_O, messages, token_count, ["comment"], + GRADING_TEMPERATURE) + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Answers graded: " + str(response)) + + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Adding perfect answers to response.") + for i, answer in enumerate(perfect_answers, start=1): + response['perfect_answer_' + str(i)] = answer + + logging.info("POST - speaking_task_1 - " + str( + request_id) + " - Adding transcript and fixed texts to response.") + for i, answer in enumerate(text_answers, start=1): + response['transcript_' + str(i)] = answer + response['fixed_text_' + str(i)] = get_speaking_corrections(answer) + + if response["overall"] == "0.0" or response["overall"] == 0.0: + response["overall"] = round((response["task_response"]["Fluency and Coherence"] + + response["task_response"]["Lexical Resource"] + response["task_response"][ + "Grammatical Range and Accuracy"] + response["task_response"][ + "Pronunciation"]) / 4, 1) + + logging.info("POST - speaking_task_1 - " + str(request_id) + " - Final response: " + str(response)) + return response except Exception as e: - os.remove(sound_file_name) return str(e), 400 @@ -558,37 +592,53 @@ def grade_speaking_task_1(): @jwt_required() def get_speaking_task_1_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - topic = request.args.get("topic", default=random.choice(mti_topics)) + first_topic = request.args.get("first_topic", default=random.choice(mti_topics)) + second_topic = request.args.get("second_topic", default=random.choice(mti_topics)) + + json_format = { + "first_topic": "topic 1", + "second_topic": "topic 2", + "questions": [ + "Introductory question, should start with a greeting and introduce a question about the first topic.", + "Follow up question about the first topic", + "Follow up question about the first topic", + "Question about second topic", + "Follow up question about the second topic", + ] + } + try: messages = [ { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' - '{"topic": "topic", "question": "question"}') + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", "content": ( - 'Craft a thought-provoking question of ' + difficulty + ' difficulty for IELTS Speaking Part 1 ' - 'that encourages candidates to delve deeply into ' - 'personal experiences, preferences, or insights on the topic ' - 'of "' + topic + '". Instruct the candidate ' - 'to offer not only detailed ' - 'descriptions but also provide ' - 'nuanced explanations, examples, ' - 'or anecdotes to enrich their response. ' - 'Make sure that the generated question ' - 'does not contain forbidden subjects in ' - 'muslim countries.') + 'Craft 5 thought-provoking questions of ' + difficulty + ' difficulty for IELTS Speaking Part 1 ' + 'that encourages candidates to delve deeply into ' + 'personal experiences, preferences, or insights on the topic ' + 'of "' + first_topic + '" and the topic of "' + second_topic + '". Instruct the candidate ' + 'to offer not only detailed ' + 'descriptions but also provide ' + 'nuanced explanations, examples, ' + 'or anecdotes to enrich their response. ' + 'Make sure that the generated question ' + 'does not contain forbidden subjects in ' + 'muslim countries.') + }, + { + "role": "user", + "content": 'The questions should lead to the usage of 4 verb tenses (present perfect, present, past and future).' } ] token_count = count_total_tokens(messages) - response = make_openai_call(GPT_4_O, messages, token_count, ["topic"], + response = make_openai_call(GPT_4_O, messages, token_count, ["first_topic"], GEN_QUESTION_TEMPERATURE) response["type"] = 1 response["difficulty"] = difficulty - response["topic"] = topic return response except Exception as e: return str(e) @@ -751,16 +801,16 @@ def get_speaking_task_2_question(): "role": "user", "content": ( 'Create a question of ' + difficulty + ' difficulty for IELTS Speaking Part 2 ' - 'that encourages candidates to narrate a ' - 'personal experience or story related to the topic ' - 'of "' + topic + '". Include 3 prompts that ' - 'guide the candidate to describe ' - 'specific aspects of the experience, ' - 'such as details about the situation, ' - 'their actions, and the reasons it left a ' - 'lasting impression. Make sure that the ' - 'generated question does not contain ' - 'forbidden subjects in muslim countries.') + 'that encourages candidates to narrate a ' + 'personal experience or story related to the topic ' + 'of "' + topic + '". Include 3 prompts that ' + 'guide the candidate to describe ' + 'specific aspects of the experience, ' + 'such as details about the situation, ' + 'their actions, and the reasons it left a ' + 'lasting impression. Make sure that the ' + 'generated question does not contain ' + 'forbidden subjects in muslim countries.') } ] token_count = count_total_tokens(messages) @@ -884,6 +934,7 @@ def grade_speaking_task_3(): token_count, ["answer"], GEN_QUESTION_TEMPERATURE)) + json_format = { "comment": "extensive comment about answer quality", "overall": 0.0, @@ -907,20 +958,6 @@ def grade_speaking_task_3(): } } - messages = [ - { - "role": "system", - "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) - } - ] - message = ( - "Evaluate the given Speaking Part 3 response based on the IELTS grading system, ensuring a " - "strict assessment that penalizes errors. Deduct points for deviations from the task, and " - "assign a score of 0 if the response fails to address the question. Additionally, provide detailed " - "commentary highlighting both strengths and weaknesses in the response." - "\n\n The questions and answers are: \n\n'") - logging.info("POST - speaking_task_3 - " + str(request_id) + " - Formatting answers and questions for prompt.") formatted_text = "" for i, entry in enumerate(answers, start=1): @@ -929,17 +966,36 @@ def grade_speaking_task_3(): logging.info("POST - speaking_task_3 - " + str( request_id) + " - Formatted answers and questions for prompt: " + formatted_text) - message += formatted_text + grade_message = ( + "Evaluate the given Speaking Part 3 response based on the IELTS grading system, ensuring a " + "strict assessment that penalizes errors. Deduct points for deviations from the task, and " + "assign a score of 0 if the response fails to address the question. Additionally, provide detailed " + "commentary highlighting both strengths and weaknesses in the response." + "\n\n The questions and answers are: \n\n'") - messages.append({ - "role": "user", - "content": message - }) - - messages.append({ - "role": "user", - "content": 'Address the student as "you"' - }) + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + }, + { + "role": "user", + "content": grade_message + }, + { + "role": "user", + "content": 'Address the student as "you".' + }, + { + "role": "user", + "content": 'For pronunciations act as if you heard the answers and they were transcripted as you heard them.' + }, + { + "role": "user", + "content": 'The comments must be long, detailed, justify the grading and suggest improvements.' + } + ] token_count = count_total_tokens(messages) diff --git a/helper/heygen_api.py b/helper/heygen_api.py index 149ed70..d0e2d8c 100644 --- a/helper/heygen_api.py +++ b/helper/heygen_api.py @@ -29,26 +29,32 @@ GET_HEADER = { def create_videos_and_save_to_db(exercises, template, id): + avatar = random.choice(list(AvatarEnum)) # Speaking 1 # Using list comprehension to find the element with the desired value in the 'type' field found_exercises_1 = [element for element in exercises if element.get('type') == 1] # Check if any elements were found if found_exercises_1: exercise_1 = found_exercises_1[0] + sp1_questions = [] app.app.logger.info('Creating video for speaking part 1') - sp1_result = create_video(exercise_1["question"], random.choice(list(AvatarEnum))) - if sp1_result is not None: - sound_file_path = VIDEO_FILES_PATH + sp1_result - firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + sp1_result - url = upload_file_firebase_get_url(FIREBASE_BUCKET, firebase_file_path, sound_file_path) - sp1_video_path = firebase_file_path - sp1_video_url = url - template["exercises"][0]["text"] = exercise_1["question"] - template["exercises"][0]["title"] = exercise_1["topic"] - template["exercises"][0]["video_url"] = sp1_video_url - template["exercises"][0]["video_path"] = sp1_video_path - else: - app.app.logger.error("Failed to create video for part 1 question: " + exercise_1["question"]) + for question in exercise_1["questions"]: + sp1_result = create_video(question, avatar) + if sp1_result is not None: + sound_file_path = VIDEO_FILES_PATH + sp1_result + firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + sp1_result + url = upload_file_firebase_get_url(FIREBASE_BUCKET, firebase_file_path, sound_file_path) + video = { + "text": question, + "video_path": firebase_file_path, + "video_url": url + } + sp1_questions.append(video) + else: + app.app.logger.error("Failed to create video for part 1 question: " + exercise_1["question"]) + template["exercises"][0]["prompts"] = sp1_questions + template["exercises"][0]["first_title"] = exercise_1["first_topic"] + template["exercises"][0]["second_title"] = exercise_1["second_topic"] # Speaking 2 # Using list comprehension to find the element with the desired value in the 'type' field @@ -57,7 +63,7 @@ def create_videos_and_save_to_db(exercises, template, id): if found_exercises_2: exercise_2 = found_exercises_2[0] app.app.logger.info('Creating video for speaking part 2') - sp2_result = create_video(exercise_2["question"], random.choice(list(AvatarEnum))) + sp2_result = create_video(exercise_2["question"], avatar) if sp2_result is not None: sound_file_path = VIDEO_FILES_PATH + sp2_result firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + sp2_result @@ -79,7 +85,6 @@ def create_videos_and_save_to_db(exercises, template, id): if found_exercises_3: exercise_3 = found_exercises_3[0] sp3_questions = [] - avatar = random.choice(list(AvatarEnum)) app.app.logger.info('Creating videos for speaking part 3') for question in exercise_3["questions"]: result = create_video(question, avatar) diff --git a/helper/question_templates.py b/helper/question_templates.py index b065626..a6edfa8 100644 --- a/helper/question_templates.py +++ b/helper/question_templates.py @@ -1136,12 +1136,11 @@ def getSpeakingTemplate(): "exercises": [ { "id": str(uuid.uuid4()), - "prompts": [], - "text": "text", - "title": "topic", - "video_url": "sp1_video_url", - "video_path": "sp1_video_path", - "type": "speaking" + "prompts": ["questions"], + "text": "Listen carefully and respond.", + "first_title": "first_topic", + "second_title": "second_topic", + "type": "interactiveSpeaking" }, { "id": str(uuid.uuid4()), From 545aee1a191a39f461ae0c3fa3c1836a090d0971 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 31 May 2024 23:08:46 +0100 Subject: [PATCH 04/44] Improve prompts and add suffix to speaking 2. --- app.py | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/app.py b/app.py index c3cd735..7c7d768 100644 --- a/app.py +++ b/app.py @@ -789,28 +789,43 @@ def grade_speaking_task_2(): def get_speaking_task_2_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) topic = request.args.get("topic", default=random.choice(mti_topics)) + + json_format = { + "topic": "topic", + "question": "question", + "prompts": [ + "prompt_1", + "prompt_2", + "prompt_3" + ], + "suffix": "And explain why..." + } + try: messages = [ { "role": "system", - "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' - '{"topic": "topic", "question": "question", "prompts": ["prompt_1", "prompt_2", "prompt_3"]}') + "content": 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format) }, { "role": "user", "content": ( - 'Create a question of ' + difficulty + ' difficulty for IELTS Speaking Part 2 ' - 'that encourages candidates to narrate a ' - 'personal experience or story related to the topic ' - 'of "' + topic + '". Include 3 prompts that ' - 'guide the candidate to describe ' - 'specific aspects of the experience, ' - 'such as details about the situation, ' - 'their actions, and the reasons it left a ' - 'lasting impression. Make sure that the ' - 'generated question does not contain ' - 'forbidden subjects in muslim countries.') + 'Create a question of medium difficulty for IELTS Speaking Part 2 ' + 'that encourages candidates to narrate a ' + 'personal experience or story related to the topic ' + 'of "' + random.choice(mti_topics) + '". Include 3 prompts that ' + 'guide the candidate to describe ' + 'specific aspects of the experience, ' + 'such as details about the situation, ' + 'their actions, and the reasons it left a ' + 'lasting impression. Make sure that the ' + 'generated question does not contain ' + 'forbidden subjects in muslim countries.') + }, + { + "role": "user", + "content": 'The prompts must not be questions. Also include a suffix like the ones in the IELTS exams ' + 'that start with "And explain why".' } ] token_count = count_total_tokens(messages) From ee5f23b3d70c3e4d607a193d4f891d573a748380 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 31 May 2024 23:41:11 +0100 Subject: [PATCH 05/44] Update speaking 3 to have 5 questions. --- app.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/app.py b/app.py index 7c7d768..e7fa177 100644 --- a/app.py +++ b/app.py @@ -843,21 +843,32 @@ def get_speaking_task_2_question(): def get_speaking_task_3_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) topic = request.args.get("topic", default=random.choice(mti_topics)) + + json_format = { + "topic": "topic", + "questions": [ + "Introductory question, should start with a greeting and introduce a question about the topic.", + "Follow up question about the topic", + "Follow up question about the topic", + "Follow up question about the topic", + "Follow up question about the topic" + ] + } try: messages = [ { "role": "system", "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' - '{"topic": "topic", "questions": ["question", "question", "question"]}') + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", "content": ( - 'Formulate a set of 3 questions of ' + difficulty + ' difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' - 'meaningful discussion on the topic of "' + topic + '". Provide inquiries, ensuring ' - 'they explore various aspects, perspectives, and implications related to the topic.' - 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') + 'Formulate a set of 5 questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' + 'meaningful discussion on the topic of "' + random.choice( + mti_topics) + '". Provide inquiries, ensuring ' + 'they explore various aspects, perspectives, and implications related to the topic.' + 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') } ] @@ -1000,7 +1011,7 @@ def grade_speaking_task_3(): }, { "role": "user", - "content": 'Address the student as "you".' + "content": 'Address the student as "you" and pay special attention to coherence between the answers.' }, { "role": "user", @@ -1015,7 +1026,7 @@ def grade_speaking_task_3(): token_count = count_total_tokens(messages) logging.info("POST - speaking_task_3 - " + str(request_id) + " - Requesting grading of the answers.") - response = make_openai_call(GPT_3_5_TURBO, messages, token_count, ["comment"], GRADING_TEMPERATURE) + response = make_openai_call(GPT_4_O, messages, token_count, ["comment"], GRADING_TEMPERATURE) logging.info("POST - speaking_task_3 - " + str(request_id) + " - Answers graded: " + str(response)) logging.info("POST - speaking_task_3 - " + str(request_id) + " - Adding perfect answers to response.") From b93ead3a7b71d63e44653dd2dda71ddb388cc920 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Mon, 17 Jun 2024 22:51:59 +0100 Subject: [PATCH 06/44] Update speaking generation endpoints. --- app.py | 136 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 22 deletions(-) diff --git a/app.py b/app.py index e7fa177..a2a2fe2 100644 --- a/app.py +++ b/app.py @@ -351,12 +351,12 @@ def grade_writing_task_2(): { "role": "user", "content": ( - 'Evaluate the given Writing Task 2 response based on the IELTS grading system, ensuring a ' - 'strict assessment that penalizes errors. Deduct points for deviations from the task, and ' - 'assign a score of 0 if the response fails to address the question. Additionally, provide an ' - 'exemplary answer with a minimum of 250 words, along with a detailed commentary highlighting ' - 'both strengths and weaknesses in the response.' - '\n Question: "' + question + '" \n Answer: "' + answer + '"') + 'Evaluate the given Writing Task 2 response based on the IELTS grading system, ensuring a ' + 'strict assessment that penalizes errors. Deduct points for deviations from the task, and ' + 'assign a score of 0 if the response fails to address the question. Additionally, provide an ' + 'exemplary answer with a minimum of 250 words, along with a detailed commentary highlighting ' + 'both strengths and weaknesses in the response.' + '\n Question: "' + question + '" \n Answer: "' + answer + '"') }, { "role": "user", @@ -1080,21 +1080,91 @@ def save_speaking(): return str(e) -@app.route("/speaking/generate_speaking_video", methods=['POST']) +@app.route("/speaking/generate_video_1", methods=['POST']) @jwt_required() -def generate_speaking_video(): +def generate_video_1(): + try: + data = request.get_json() + sp3_questions = [] + avatar = data.get("avatar", random.choice(list(AvatarEnum)).value) + + request_id = str(uuid.uuid4()) + logging.info("POST - generate_video_1 - Received request to generate video 1. " + "Use this id to track the logs: " + str(request_id) + " - Request data: " + str( + request.get_json())) + + logging.info("POST - generate_video_1 - " + str(request_id) + " - Creating videos for speaking part 1.") + for question in data["questions"]: + logging.info("POST - generate_video_1 - " + str(request_id) + " - Creating video for question: " + question) + result = create_video(question, avatar) + logging.info("POST - generate_video_1 - " + str(request_id) + " - Video created: " + result) + if result is not None: + sound_file_path = VIDEO_FILES_PATH + result + firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + result + logging.info( + "POST - generate_video_1 - " + str( + request_id) + " - Uploading video to firebase: " + firebase_file_path) + url = upload_file_firebase_get_url(FIREBASE_BUCKET, firebase_file_path, sound_file_path) + logging.info( + "POST - generate_video_1 - " + str( + request_id) + " - Uploaded video to firebase: " + url) + video = { + "text": question, + "video_path": firebase_file_path, + "video_url": url + } + sp3_questions.append(video) + else: + logging.error("POST - generate_video_1 - " + str( + request_id) + " - Failed to create video for part 1 question: " + question) + + response = { + "prompts": sp3_questions, + "first_title": data["first_topic"], + "second_title": data["second_topic"], + "type": "interactiveSpeaking", + "id": uuid.uuid4() + } + logging.info( + "POST - generate_video_1 - " + str( + request_id) + " - Finished creating videos for speaking part 1: " + str(response)) + return response + except Exception as e: + return str(e) + + +@app.route("/speaking/generate_video_2", methods=['POST']) +@jwt_required() +def generate_video_2(): try: data = request.get_json() avatar = data.get("avatar", random.choice(list(AvatarEnum)).value) prompts = data.get("prompts", []) question = data.get("question") - if len(prompts) > 0: - question = question + " In your answer you should consider: " + " ".join(prompts) - sp1_result = create_video(question, avatar) - if sp1_result is not None: - sound_file_path = VIDEO_FILES_PATH + sp1_result - firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + sp1_result + suffix = data.get("suffix", "") + + question = question + " In your answer you should consider: " + " ".join(prompts) + suffix + + request_id = str(uuid.uuid4()) + logging.info("POST - generate_video_2 - Received request to generate video 2. " + "Use this id to track the logs: " + str(request_id) + " - Request data: " + str( + request.get_json())) + + logging.info("POST - generate_video_2 - " + str(request_id) + " - Creating video for speaking part 2.") + logging.info("POST - generate_video_2 - " + str(request_id) + " - Creating video for question: " + question) + result = create_video(question, avatar) + logging.info("POST - generate_video_2 - " + str(request_id) + " - Video created: " + result) + + if result is not None: + sound_file_path = VIDEO_FILES_PATH + result + firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + result + logging.info( + "POST - generate_video_2 - " + str( + request_id) + " - Uploading video to firebase: " + firebase_file_path) url = upload_file_firebase_get_url(FIREBASE_BUCKET, firebase_file_path, sound_file_path) + logging.info( + "POST - generate_video_2 - " + str( + request_id) + " - Uploaded video to firebase: " + url) sp1_video_path = firebase_file_path sp1_video_url = url @@ -1105,31 +1175,47 @@ def generate_speaking_video(): "video_url": sp1_video_url, "video_path": sp1_video_path, "type": "speaking", - "id": uuid.uuid4() + "id": uuid.uuid4(), + "suffix": suffix } else: - app.logger.error("Failed to create video for part 1 question: " + data["question"]) - return str("Failed to create video for part 1 question: " + data["question"]) + logging.error("POST - generate_video_2 - " + str( + request_id) + " - Failed to create video for part 2 question: " + question) + return str("Failed to create video for part 2 question: " + data["question"]) except Exception as e: return str(e) -@app.route("/speaking/generate_interactive_video", methods=['POST']) +@app.route("/speaking/generate_video_3", methods=['POST']) @jwt_required() -def generate_interactive_video(): +def generate_video_3(): try: data = request.get_json() sp3_questions = [] avatar = data.get("avatar", random.choice(list(AvatarEnum)).value) - app.logger.info('Creating videos for speaking part 3') + request_id = str(uuid.uuid4()) + logging.info("POST - generate_video_3 - Received request to generate video 3. " + "Use this id to track the logs: " + str(request_id) + " - Request data: " + str( + request.get_json())) + + logging.info("POST - generate_video_3 - " + str(request_id) + " - Creating videos for speaking part 3.") for question in data["questions"]: + logging.info("POST - generate_video_3 - " + str(request_id) + " - Creating video for question: " + question) result = create_video(question, avatar) + logging.info("POST - generate_video_3 - " + str(request_id) + " - Video created: " + result) + if result is not None: sound_file_path = VIDEO_FILES_PATH + result firebase_file_path = FIREBASE_SPEAKING_VIDEO_FILES_PATH + result + logging.info( + "POST - generate_video_3 - " + str( + request_id) + " - Uploading video to firebase: " + firebase_file_path) url = upload_file_firebase_get_url(FIREBASE_BUCKET, firebase_file_path, sound_file_path) + logging.info( + "POST - generate_video_3 - " + str( + request_id) + " - Uploaded video to firebase: " + url) video = { "text": question, "video_path": firebase_file_path, @@ -1137,14 +1223,19 @@ def generate_interactive_video(): } sp3_questions.append(video) else: - app.app.logger.error("Failed to create video for part 3 question: " + question) + logging.error("POST - generate_video_3 - " + str( + request_id) + " - Failed to create video for part 3 question: " + question) - return { + response = { "prompts": sp3_questions, "title": data["topic"], "type": "interactiveSpeaking", "id": uuid.uuid4() } + logging.info( + "POST - generate_video_3 - " + str( + request_id) + " - Finished creating videos for speaking part 3: " + str(response)) + return response except Exception as e: return str(e) @@ -1203,6 +1294,7 @@ def get_level_exam(): except Exception as e: return str(e) + @app.route('/level_utas', methods=['GET']) @jwt_required() def get_level_utas(): From 2adb7d1847602042379b18d818b23aa053ddd53e Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 25 Jun 2024 20:49:27 +0100 Subject: [PATCH 07/44] Listening part 1. --- app.py | 5 +++-- helper/constants.py | 2 ++ helper/exercises.py | 20 ++++++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index a2a2fe2..0072408 100644 --- a/app.py +++ b/app.py @@ -53,7 +53,7 @@ def get_listening_section_1_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_EXERCISE_TYPES, 1) + req_exercises = random.sample(LISTENING_1_EXERCISE_TYPES, 1) number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_1_EXERCISES, len(req_exercises)) @@ -62,7 +62,8 @@ def get_listening_section_1_question(): app.logger.info("Generated conversation: " + str(processed_conversation)) start_id = 1 - exercises = generate_listening_conversation_exercises(parse_conversation(processed_conversation), req_exercises, + exercises = generate_listening_conversation_exercises(parse_conversation(processed_conversation), + req_exercises, number_of_exercises_q, start_id, difficulty) return { diff --git a/helper/constants.py b/helper/constants.py index c5f924c..3074171 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -19,6 +19,8 @@ GEN_TEXT_FIELDS = ['title'] LISTENING_GEN_FIELDS = ['transcript', 'exercise'] READING_EXERCISE_TYPES = ['fillBlanks', 'writeBlanks', 'trueFalse', 'paragraphMatch'] LISTENING_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksForm'] +LISTENING_1_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksFill', + 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm'] TOTAL_READING_PASSAGE_1_EXERCISES = 13 TOTAL_READING_PASSAGE_2_EXERCISES = 13 diff --git a/helper/exercises.py b/helper/exercises.py index 1d05bee..5b954e4 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -283,6 +283,16 @@ def generate_listening_1_conversation(topic: str): 'Make sure that the generated conversation does not contain forbidden subjects in ' 'muslim countries.') + }, + { + "role": "user", + "content": 'Try to have misleading discourse (refer multiple dates, multiple colors and etc).' + + }, + { + "role": "user", + "content": 'Try to have spelling of names (cities, people, etc)' + } ] token_count = count_total_tokens(messages) @@ -951,13 +961,19 @@ def gen_write_blanks_form_exercise_listening_conversation(text: str, quantity: i "role": "system", "content": ( 'You are a helpful assistant designed to output JSON on this format: ' - '{"form": ["key: value", "key2: value"]}') + '{"form": ["key": "value", "key2": "value"]}') }, { "role": "user", "content": ( 'Generate a form with ' + str( - quantity) + ' ' + difficulty + ' difficulty key-value pairs about this conversation:\n"' + text + '"') + quantity) + ' entries with information about this conversation:\n"' + text + '"') + + }, + { + "role": "user", + "content": 'It must be a form and not questions. ' + 'Example: {"form": ["Color of car": "blue", "Brand of car": "toyota"]}' } ] From 9a696bbeb5d6baf24dfe62ce1c81f86a635a751e Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 27 Jun 2024 21:29:22 +0100 Subject: [PATCH 08/44] Listening part 2. --- app.py | 2 +- helper/constants.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 0072408..92edd15 100644 --- a/app.py +++ b/app.py @@ -86,7 +86,7 @@ def get_listening_section_2_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_EXERCISE_TYPES, 2) + req_exercises = random.sample(LISTENING_2_EXERCISE_TYPES, 2) number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_2_EXERCISES, len(req_exercises)) diff --git a/helper/constants.py b/helper/constants.py index 3074171..24c6281 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -21,6 +21,7 @@ READING_EXERCISE_TYPES = ['fillBlanks', 'writeBlanks', 'trueFalse', 'paragraphMa LISTENING_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksForm'] LISTENING_1_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksFill', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm'] +LISTENING_2_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions'] TOTAL_READING_PASSAGE_1_EXERCISES = 13 TOTAL_READING_PASSAGE_2_EXERCISES = 13 From a3cd1cdf590e8d129824a95e03065a2b1c1b6876 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 27 Jun 2024 22:03:59 +0100 Subject: [PATCH 09/44] Listening part 3 and 4. --- app.py | 2 +- helper/constants.py | 2 ++ helper/exercises.py | 17 +++++++++++------ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index 92edd15..e0afe36 100644 --- a/app.py +++ b/app.py @@ -116,7 +116,7 @@ def get_listening_section_3_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_EXERCISE_TYPES, 1) + req_exercises = random.sample(LISTENING_3_EXERCISE_TYPES, 1) number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_3_EXERCISES, len(req_exercises)) diff --git a/helper/constants.py b/helper/constants.py index 24c6281..67df516 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -22,6 +22,8 @@ LISTENING_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlan LISTENING_1_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksFill', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm'] LISTENING_2_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions'] +LISTENING_3_EXERCISE_TYPES = ['multipleChoice3Options', 'writeBlanksQuestions'] +LISTENING_4_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksForm'] TOTAL_READING_PASSAGE_1_EXERCISES = 13 TOTAL_READING_PASSAGE_2_EXERCISES = 13 diff --git a/helper/exercises.py b/helper/exercises.py index 5b954e4..8c12869 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -410,7 +410,7 @@ def generate_listening_4_monologue(topic: str): { "role": "user", "content": ( - 'Generate a comprehensive monologue on the academic subject ' + 'Generate a comprehensive and complex monologue on the academic subject ' 'of: "' + topic + '". Make sure that the generated monologue does not contain forbidden subjects in ' 'muslim countries.') @@ -477,7 +477,12 @@ def generate_listening_conversation_exercises(conversation: str, req_exercises: if req_exercise == "multipleChoice": question = gen_multiple_choice_exercise_listening_conversation(conversation, number_of_exercises, start_id, - difficulty) + difficulty, 4) + exercises.append(question) + print("Added multiple choice: " + str(question)) + elif req_exercise == "multipleChoice3Options": + question = gen_multiple_choice_exercise_listening_conversation(conversation, number_of_exercises, start_id, + difficulty, 3) exercises.append(question) print("Added multiple choice: " + str(question)) elif req_exercise == "writeBlanksQuestions": @@ -733,7 +738,7 @@ def assign_letters_to_paragraphs(paragraphs): return result -def gen_multiple_choice_exercise_listening_conversation(text: str, quantity: int, start_id, difficulty): +def gen_multiple_choice_exercise_listening_conversation(text: str, quantity: int, start_id, difficulty, n_options=4): messages = [ { "role": "system", @@ -747,7 +752,7 @@ def gen_multiple_choice_exercise_listening_conversation(text: str, quantity: int { "role": "user", "content": ( - 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty multiple choice questions of 4 options ' + 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str(n_options) + ' options ' 'of for this conversation:\n"' + text + '"') } @@ -763,7 +768,7 @@ def gen_multiple_choice_exercise_listening_conversation(text: str, quantity: int } -def gen_multiple_choice_exercise_listening_monologue(text: str, quantity: int, start_id, difficulty): +def gen_multiple_choice_exercise_listening_monologue(text: str, quantity: int, start_id, difficulty, n_options=4): messages = [ { "role": "system", @@ -778,7 +783,7 @@ def gen_multiple_choice_exercise_listening_monologue(text: str, quantity: int, s "role": "user", "content": ( 'Generate ' + str( - quantity) + ' ' + difficulty + ' difficulty multiple choice questions of 4 options ' + quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str(n_options) + ' options ' 'of for this monologue:\n"' + text + '"') } From a8b46160d4f1a8627027b4072787b3e6e39a9e79 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 27 Jun 2024 22:31:57 +0100 Subject: [PATCH 10/44] Minor fixes to speaking. --- app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index e0afe36..b1d302d 100644 --- a/app.py +++ b/app.py @@ -600,7 +600,7 @@ def get_speaking_task_1_question(): "first_topic": "topic 1", "second_topic": "topic 2", "questions": [ - "Introductory question, should start with a greeting and introduce a question about the first topic.", + "Introductory question, should start with a greeting and introduce a question about the first topic, starting the topic with 'Let's talk about x' and then the question.", "Follow up question about the first topic", "Follow up question about the first topic", "Question about second topic", @@ -1144,7 +1144,9 @@ def generate_video_2(): question = data.get("question") suffix = data.get("suffix", "") - question = question + " In your answer you should consider: " + " ".join(prompts) + suffix + # Removed as the examiner should not say what is on the card. + # question = question + " In your answer you should consider: " + " ".join(prompts) + suffix + question = question + "\nYou have 1 minute to take notes." request_id = str(uuid.uuid4()) logging.info("POST - generate_video_2 - Received request to generate video 2. " From e693f5ee2a311100b33e1791a746b8d729bed077 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 27 Jun 2024 22:48:42 +0100 Subject: [PATCH 11/44] Make speaking 1 questions simple. --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index b1d302d..5dcc446 100644 --- a/app.py +++ b/app.py @@ -592,7 +592,7 @@ def grade_speaking_task_1(): @app.route('/speaking_task_1', methods=['GET']) @jwt_required() def get_speaking_task_1_question(): - difficulty = request.args.get("difficulty", default=random.choice(difficulties)) + difficulty = request.args.get("difficulty", default="easy") first_topic = request.args.get("first_topic", default=random.choice(mti_topics)) second_topic = request.args.get("second_topic", default=random.choice(mti_topics)) @@ -618,7 +618,7 @@ def get_speaking_task_1_question(): { "role": "user", "content": ( - 'Craft 5 thought-provoking questions of ' + difficulty + ' difficulty for IELTS Speaking Part 1 ' + 'Craft 5 simple questions of easy difficulty for IELTS Speaking Part 1 ' 'that encourages candidates to delve deeply into ' 'personal experiences, preferences, or insights on the topic ' 'of "' + first_topic + '" and the topic of "' + second_topic + '". Instruct the candidate ' From 565874ad414145bdcbb9d73bbac3e97bb90509c7 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 28 Jun 2024 18:33:42 +0100 Subject: [PATCH 12/44] Minor improvements to speaking. --- app.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 5dcc446..98a71b4 100644 --- a/app.py +++ b/app.py @@ -814,7 +814,7 @@ def get_speaking_task_2_question(): 'Create a question of medium difficulty for IELTS Speaking Part 2 ' 'that encourages candidates to narrate a ' 'personal experience or story related to the topic ' - 'of "' + random.choice(mti_topics) + '". Include 3 prompts that ' + 'of "' + topic + '". Include 3 prompts that ' 'guide the candidate to describe ' 'specific aspects of the experience, ' 'such as details about the situation, ' @@ -848,7 +848,7 @@ def get_speaking_task_3_question(): json_format = { "topic": "topic", "questions": [ - "Introductory question, should start with a greeting and introduce a question about the topic.", + "Introductory question about the topic.", "Follow up question about the topic", "Follow up question about the topic", "Follow up question about the topic", @@ -866,8 +866,7 @@ def get_speaking_task_3_question(): "role": "user", "content": ( 'Formulate a set of 5 questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' - 'meaningful discussion on the topic of "' + random.choice( - mti_topics) + '". Provide inquiries, ensuring ' + 'meaningful discussion on the topic of "' + topic + '". Provide inquiries, ensuring ' 'they explore various aspects, perspectives, and implications related to the topic.' 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') From afca610c099bce64cd651c2edbedde7c08347f92 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Mon, 15 Jul 2024 18:21:06 +0100 Subject: [PATCH 13/44] Fix level test generation. --- helper/exercises.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/helper/exercises.py b/helper/exercises.py index 8c12869..85d187c 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -1085,11 +1085,12 @@ def replace_exercise_if_exists(all_exams, current_exercise, current_exam, seen_k for exam in all_exams: exam_dict = exam.to_dict() + exercise_dict = exam_dict.get("parts", [])[0] if any( exercise["prompt"] == current_exercise["prompt"] and any(exercise["options"][0]["text"] == current_option["text"] for current_option in current_exercise["options"]) - for exercise in exam_dict.get("exercises", [])[0]["questions"] + for exercise in exercise_dict.get("exercises", [])[0]["questions"] ): return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) return current_exercise, seen_keys From b4dc6be92779740a4f818c25c28614d42b8ff687 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 16 Jul 2024 21:35:36 +0100 Subject: [PATCH 14/44] Add comment to grading of writing. --- app.py | 165 +++++++++++++++++++++++++++++++++----------- helper/exercises.py | 21 ++++++ 2 files changed, 145 insertions(+), 41 deletions(-) diff --git a/app.py b/app.py index 98a71b4..24ecb52 100644 --- a/app.py +++ b/app.py @@ -222,10 +222,22 @@ def grade_writing_task_1(): 'comment': "The answer does not contain enough english words.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': 0, - 'Grammatical Range and Accuracy': 0, - 'Lexical Resource': 0, - 'Task Achievement': 0 + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Task Achievement': { + "grade": 0.0, + "comment": "" + } } } elif not has_x_words(answer, 100): @@ -233,40 +245,68 @@ def grade_writing_task_1(): 'comment': "The answer is insufficient and too small to be graded.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': 0, - 'Grammatical Range and Accuracy': 0, - 'Lexical Resource': 0, - 'Task Achievement': 0 + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Task Achievement': { + "grade": 0.0, + "comment": "" + } } } else: + json_format = { + "comment": "comment about student's response quality", + "overall": 0.0, + "task_response": { + "Coherence and Cohesion": { + "grade": 0.0, + "comment": "comment about Coherence and Cohesion of the student's response" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "comment about Grammatical Range and Accuracy of the student's response" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "comment about Lexical Resource of the student's response" + }, + "Task Achievement": { + "grade": 0.0, + "comment": "comment about Task Achievement of the student's response" + } + } + } + messages = [ { "role": "system", - "content": ('You are a helpful assistant designed to output JSON on this format: ' - '{"perfect_answer": "example perfect answer", "comment": ' - '"comment about answer quality", "overall": 0.0, "task_response": ' - '{"Task Achievement": 0.0, "Coherence and Cohesion": 0.0, ' - '"Lexical Resource": 0.0, "Grammatical Range and Accuracy": 0.0 }') + "content": ('You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", "content": ('Evaluate the given Writing Task 1 response based on the IELTS grading system, ' 'ensuring a strict assessment that penalizes errors. Deduct points for deviations ' 'from the task, and assign a score of 0 if the response fails to address the question. ' - 'Additionally, provide an exemplary answer with a minimum of 150 words, along with a ' - 'detailed commentary highlighting both strengths and weaknesses in the response. ' + 'Additionally, provide a detailed commentary highlighting both strengths and ' + 'weaknesses in the response. ' '\n Question: "' + question + '" \n Answer: "' + answer + '"') - }, - { - "role": "user", - "content": 'The perfect answer must have at least 150 words.' } ] token_count = count_total_tokens(messages) response = make_openai_call(GPT_3_5_TURBO, messages, token_count, ["comment"], GRADING_TEMPERATURE) + response["perfect_answer"] = get_perfect_answer(question, 150)["perfect_answer"] response["overall"] = fix_writing_overall(response["overall"], response["task_response"]) response['fixed_text'] = get_fixed_text(answer) return response @@ -322,10 +362,22 @@ def grade_writing_task_2(): 'comment': "The answer does not contain enough english words.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': 0, - 'Grammatical Range and Accuracy': 0, - 'Lexical Resource': 0, - 'Task Achievement': 0 + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Task Achievement': { + "grade": 0.0, + "comment": "" + } } } elif not has_x_words(answer, 180): @@ -333,40 +385,68 @@ def grade_writing_task_2(): 'comment': "The answer is insufficient and too small to be graded.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': 0, - 'Grammatical Range and Accuracy': 0, - 'Lexical Resource': 0, - 'Task Achievement': 0 + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Task Achievement': { + "grade": 0.0, + "comment": "" + } } } else: + json_format = { + "comment": "comment about student's response quality", + "overall": 0.0, + "task_response": { + "Coherence and Cohesion": { + "grade": 0.0, + "comment": "comment about Coherence and Cohesion of the student's response" + }, + "Grammatical Range and Accuracy": { + "grade": 0.0, + "comment": "comment about Grammatical Range and Accuracy of the student's response" + }, + "Lexical Resource": { + "grade": 0.0, + "comment": "comment about Lexical Resource of the student's response" + }, + "Task Achievement": { + "grade": 0.0, + "comment": "comment about Task Achievement of the student's response" + } + } + } + messages = [ { "role": "system", - "content": ('You are a helpful assistant designed to output JSON on this format: ' - '{"perfect_answer": "example perfect answer", "comment": ' - '"comment about answer quality", "overall": 0.0, "task_response": ' - '{"Task Achievement": 0.0, "Coherence and Cohesion": 0.0, ' - '"Lexical Resource": 0.0, "Grammatical Range and Accuracy": 0.0 }') + "content": ('You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) }, { "role": "user", "content": ( 'Evaluate the given Writing Task 2 response based on the IELTS grading system, ensuring a ' 'strict assessment that penalizes errors. Deduct points for deviations from the task, and ' - 'assign a score of 0 if the response fails to address the question. Additionally, provide an ' - 'exemplary answer with a minimum of 250 words, along with a detailed commentary highlighting ' + 'assign a score of 0 if the response fails to address the question. Additionally, provide' + ' a detailed commentary highlighting ' 'both strengths and weaknesses in the response.' '\n Question: "' + question + '" \n Answer: "' + answer + '"') - }, - { - "role": "user", - "content": 'The perfect answer must have at least 250 words.' } ] token_count = count_total_tokens(messages) response = make_openai_call(GPT_4_O, messages, token_count, ["comment"], GEN_QUESTION_TEMPERATURE) + response["perfect_answer"] = get_perfect_answer(question, 250)["perfect_answer"] response["overall"] = fix_writing_overall(response["overall"], response["task_response"]) response['fixed_text'] = get_fixed_text(answer) return response @@ -375,11 +455,14 @@ def grade_writing_task_2(): def fix_writing_overall(overall: float, task_response: dict): - if overall > max(task_response.values()) or overall < min(task_response.values()): - total_sum = sum(task_response.values()) - average = total_sum / len(task_response.values()) + grades = [category["grade"] for category in task_response.values()] + + if overall > max(grades) or overall < min(grades): + total_sum = sum(grades) + average = total_sum / len(grades) rounded_average = round(average, 0) return rounded_average + return overall diff --git a/helper/exercises.py b/helper/exercises.py index 85d187c..776c0cb 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -238,6 +238,27 @@ def build_write_blanks_solutions_listening(words: [], start_id): ) return solutions +def get_perfect_answer(question: str, size: int): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"perfect_answer": "perfect answer for the question"}') + }, + { + "role": "user", + "content": ('Write a perfect answer for this writing exercise of a IELTS exam. Question: ' + question) + + }, + { + "role": "user", + "content": ('The answer must have at least ' + str(size) + ' words') + + } + ] + token_count = count_total_tokens(messages) + return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) def generate_reading_passage(type: QuestionType, topic: str): messages = [ From e7d84b9704529eaabdf315d686b3067181b86453 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 16 Jul 2024 23:38:35 +0100 Subject: [PATCH 15/44] Fix paragraph match bug. --- helper/constants.py | 2 +- helper/exercises.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/helper/constants.py b/helper/constants.py index 67df516..72d123b 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -40,7 +40,7 @@ SPEAKING_MIN_TIMER_DEFAULT = 14 BLACKLISTED_WORDS = ["jesus", "sex", "gay", "lesbian", "homosexual", "god", "angel", "pornography", "beer", "wine", "cocaine", "alcohol", "nudity", "lgbt", "casino", "gambling", "catholicism", - "discrimination", "politics", "politic", "christianity", "islam", "christian", "christians", + "discrimination", "politic", "christianity", "islam", "christian", "christians", "jews", "jew", "discrimination", "discriminatory"] EN_US_VOICES = [ diff --git a/helper/exercises.py b/helper/exercises.py index 776c0cb..3ae72b0 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -725,7 +725,7 @@ def gen_paragraph_match_exercise(text: str, quantity: int, start_id): options = [] for i, paragraph in enumerate(paragraphs, start=0): - paragraph["heading"] = headings[i] + paragraph["heading"] = headings[i]["heading"] options.append({ "id": paragraph["letter"], "sentence": paragraph["paragraph"] From 358f240d169caf738ad282bdbb6303534eb1eb61 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 18 Jul 2024 19:07:38 +0100 Subject: [PATCH 16/44] Update reading fill the blanks. --- helper/constants.py | 1 - helper/exercises.py | 122 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/helper/constants.py b/helper/constants.py index 72d123b..883ae9a 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -173,7 +173,6 @@ topics = [ "Space Exploration", "Artificial Intelligence", "Climate Change", - "World Religions", "The Human Brain", "Renewable Energy", "Cultural Diversity", diff --git a/helper/exercises.py b/helper/exercises.py index 3ae72b0..c3022bd 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -22,7 +22,7 @@ def gen_reading_passage_1(topic, req_exercises, difficulty): number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_1_EXERCISES, len(req_exercises)) - passage = generate_reading_passage(QuestionType.READING_PASSAGE_1, topic) + passage = generate_reading_passage_1_text(topic) if passage == "": return gen_reading_passage_1(topic, req_exercises, difficulty) start_id = 1 @@ -45,7 +45,7 @@ def gen_reading_passage_2(topic, req_exercises, difficulty): number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_2_EXERCISES, len(req_exercises)) - passage = generate_reading_passage(QuestionType.READING_PASSAGE_2, topic) + passage = generate_reading_passage_2_text(topic) if passage == "": return gen_reading_passage_2(topic, req_exercises, difficulty) start_id = 14 @@ -68,7 +68,7 @@ def gen_reading_passage_3(topic, req_exercises, difficulty): number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_3_EXERCISES, len(req_exercises)) - passage = generate_reading_passage(QuestionType.READING_PASSAGE_3, topic) + passage = generate_reading_passage_3_text(topic) if passage == "": return gen_reading_passage_3(topic, req_exercises, difficulty) start_id = 27 @@ -145,7 +145,12 @@ def add_random_words_and_shuffle(word_array, num_random_words): random.shuffle(combined_array) - return combined_array + result = [] + for i, word in enumerate(combined_array): + letter = chr(65 + i) # chr(65) is 'A' + result.append({"letter": letter, "word": word}) + + return result def fillblanks_build_solutions_array(words, start_id): @@ -260,7 +265,8 @@ def get_perfect_answer(question: str, size: int): token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) -def generate_reading_passage(type: QuestionType, topic: str): + +def generate_reading_passage_1_text(topic: str): messages = [ { "role": "system", @@ -271,7 +277,7 @@ def generate_reading_passage(type: QuestionType, topic: str): { "role": "user", "content": ( - 'Generate an extensive text for IELTS ' + type.value + ', of at least 1500 words, on the topic ' + 'Generate an extensive text for IELTS Reading Passage 1, of at least 800 words, on the topic ' 'of "' + topic + '". The passage should offer ' 'a substantial amount of information, ' 'analysis, or narrative relevant to the chosen ' @@ -282,7 +288,75 @@ def generate_reading_passage(type: QuestionType, topic: str): 'Make sure that the generated text does not ' 'contain forbidden subjects in muslim countries.') - } + }, + { + "role": "system", + "content": ('The generated text should be fairly easy to understand.') + }, + ] + token_count = count_total_tokens(messages) + return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) + + +def generate_reading_passage_2_text(topic: str): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"title": "title of the text", "text": "generated text"}') + }, + { + "role": "user", + "content": ( + 'Generate an extensive text for IELTS Reading Passage 2, of at least 800 words, on the topic ' + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') + + }, + { + "role": "system", + "content": ('The generated text should be fairly hard to understand.') + }, + ] + token_count = count_total_tokens(messages) + return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) + +def generate_reading_passage_3_text(topic: str): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"title": "title of the text", "text": "generated text"}') + }, + { + "role": "user", + "content": ( + 'Generate an extensive text for IELTS Reading Passage 3, of at least 800 words, on the topic ' + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') + + }, + { + "role": "system", + "content": ('The generated text should be very hard to understand and include different points, theories, ' + 'subtle differences of opinions from people over the specified topic .') + }, ] token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) @@ -595,18 +669,12 @@ def gen_summary_fill_blanks_exercise(text: str, quantity: int, start_id, difficu "role": "system", "content": ( 'You are a helpful assistant designed to output JSON on this format: ' - '{ "summary": "summary", "words": ["word_1", "word_2"] }') + '{ "summary": "summary" }') }, { "role": "user", "content": ('Summarize this text: "'+ text + '"') - }, - { - "role": "user", - "content": ('Select ' + str(quantity) + ' ' + difficulty + ' difficulty words, it must be words and not ' - 'expressions, from the summary.') - } ] token_count = count_total_tokens(messages) @@ -615,14 +683,34 @@ def gen_summary_fill_blanks_exercise(text: str, quantity: int, start_id, difficu ["summary"], GEN_QUESTION_TEMPERATURE) + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"words": ["word_1", "word_2"] }') + }, + { + "role": "user", + "content": ('Select ' + str(quantity) + ' ' + difficulty + ' difficulty words, it must be words and not ' + 'expressions, from this:\n' + response["summary"]) + + } + ] + token_count = count_total_tokens(messages) + + words_response = make_openai_call(GPT_4_O, messages, token_count, + ["summary"], + GEN_QUESTION_TEMPERATURE) + response["words"] = words_response["words"] replaced_summary = replace_first_occurrences_with_placeholders(response["summary"], response["words"], start_id) - options_words = add_random_words_and_shuffle(response["words"], 5) + options_words = add_random_words_and_shuffle(response["words"], 1) solutions = fillblanks_build_solutions_array(response["words"], start_id) return { "allowRepetition": True, "id": str(uuid.uuid4()), - "prompt": "Complete the summary below. Click a blank to select the corresponding word(s) for it.\\nThere are " + "prompt": "Complete the summary below. Write the letter of the corresponding word(s) for it.\\nThere are " "more words than spaces so you will not use them all. You may use any of the words more than once.", "solutions": solutions, "text": replaced_summary, @@ -1334,7 +1422,7 @@ def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=ran def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic=random.choice(mti_topics)): - passage = generate_reading_passage(QuestionType.READING_PASSAGE_1, topic) + passage = generate_reading_passage_1_text(topic) short_answer = gen_short_answer_utas(passage["text"], start_id, sa_quantity) mc_exercises = gen_text_multiple_choice_utas(passage["text"], start_id+sa_quantity, mc_quantity) return { From bef606fe14b27a0446dd8024fef24d072b1a9d5d Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 18 Jul 2024 23:20:06 +0100 Subject: [PATCH 17/44] Added new ideaMatch exercise type. --- helper/constants.py | 1 + helper/exercises.py | 292 +++++++++++++++++++++++++++++--------------- 2 files changed, 195 insertions(+), 98 deletions(-) diff --git a/helper/constants.py b/helper/constants.py index 883ae9a..fdd45e4 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -18,6 +18,7 @@ GEN_FIELDS = ['topic'] GEN_TEXT_FIELDS = ['title'] LISTENING_GEN_FIELDS = ['transcript', 'exercise'] READING_EXERCISE_TYPES = ['fillBlanks', 'writeBlanks', 'trueFalse', 'paragraphMatch'] +READING_3_EXERCISE_TYPES = ['fillBlanks', 'writeBlanks', 'trueFalse', 'paragraphMatch', 'ideaMatch'] LISTENING_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksForm'] LISTENING_1_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksFill', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm'] diff --git a/helper/exercises.py b/helper/exercises.py index c3022bd..2bb2860 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -7,7 +7,6 @@ import uuid import nltk from wonderwords import RandomWord -from helper.api_messages import QuestionType from helper.constants import * from helper.firebase_helper import get_all from helper.openai_interface import make_openai_call, count_total_tokens @@ -243,6 +242,7 @@ def build_write_blanks_solutions_listening(words: [], start_id): ) return solutions + def get_perfect_answer(question: str, size: int): messages = [ { @@ -278,20 +278,20 @@ def generate_reading_passage_1_text(topic: str): "role": "user", "content": ( 'Generate an extensive text for IELTS Reading Passage 1, of at least 800 words, on the topic ' - 'of "' + topic + '". The passage should offer ' - 'a substantial amount of information, ' - 'analysis, or narrative relevant to the chosen ' - 'subject matter. This text passage aims to ' - 'serve as the primary reading section of an ' - 'IELTS test, providing an in-depth and ' - 'comprehensive exploration of the topic. ' - 'Make sure that the generated text does not ' - 'contain forbidden subjects in muslim countries.') + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') }, { "role": "system", - "content": ('The generated text should be fairly easy to understand.') + "content": ('The generated text should be fairly easy to understand and have multiple paragraphs.') }, ] token_count = count_total_tokens(messages) @@ -310,25 +310,26 @@ def generate_reading_passage_2_text(topic: str): "role": "user", "content": ( 'Generate an extensive text for IELTS Reading Passage 2, of at least 800 words, on the topic ' - 'of "' + topic + '". The passage should offer ' - 'a substantial amount of information, ' - 'analysis, or narrative relevant to the chosen ' - 'subject matter. This text passage aims to ' - 'serve as the primary reading section of an ' - 'IELTS test, providing an in-depth and ' - 'comprehensive exploration of the topic. ' - 'Make sure that the generated text does not ' - 'contain forbidden subjects in muslim countries.') + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') }, { "role": "system", - "content": ('The generated text should be fairly hard to understand.') + "content": ('The generated text should be fairly hard to understand and have multiple paragraphs.') }, ] token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) + def generate_reading_passage_3_text(topic: str): messages = [ { @@ -341,21 +342,22 @@ def generate_reading_passage_3_text(topic: str): "role": "user", "content": ( 'Generate an extensive text for IELTS Reading Passage 3, of at least 800 words, on the topic ' - 'of "' + topic + '". The passage should offer ' - 'a substantial amount of information, ' - 'analysis, or narrative relevant to the chosen ' - 'subject matter. This text passage aims to ' - 'serve as the primary reading section of an ' - 'IELTS test, providing an in-depth and ' - 'comprehensive exploration of the topic. ' - 'Make sure that the generated text does not ' - 'contain forbidden subjects in muslim countries.') + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') }, { "role": "system", "content": ('The generated text should be very hard to understand and include different points, theories, ' - 'subtle differences of opinions from people over the specified topic .') + 'subtle differences of opinions from people, correctly sourced to the person who said it, ' + 'over the specified topic and have multiple paragraphs.') }, ] token_count = count_total_tokens(messages) @@ -464,8 +466,8 @@ def generate_listening_3_conversation(topic: str): "content": ( 'Compose an authentic and elaborate conversation between up to four individuals in the everyday ' 'social context of "' + topic + '". Please include random names and genders for the characters in your dialogue. ' - 'Make sure that the generated conversation does not contain forbidden subjects in ' - 'muslim countries.') + 'Make sure that the generated conversation does not contain forbidden subjects in ' + 'muslim countries.') } ] @@ -507,7 +509,7 @@ def generate_listening_4_monologue(topic: str): "content": ( 'Generate a comprehensive and complex monologue on the academic subject ' 'of: "' + topic + '". Make sure that the generated monologue does not contain forbidden subjects in ' - 'muslim countries.') + 'muslim countries.') } ] @@ -547,6 +549,10 @@ def generate_reading_exercises(passage: str, req_exercises: list, number_of_exer question = gen_paragraph_match_exercise(passage, number_of_exercises, start_id) exercises.append(question) print("Added paragraph match: " + str(question)) + elif req_exercise == "ideaMatch": + question = gen_idea_match_exercise(passage, number_of_exercises, start_id) + exercises.append(question) + print("Added idea match: " + str(question)) start_id = start_id + number_of_exercises @@ -673,15 +679,15 @@ def gen_summary_fill_blanks_exercise(text: str, quantity: int, start_id, difficu }, { "role": "user", - "content": ('Summarize this text: "'+ text + '"') + "content": ('Summarize this text: "' + text + '"') } ] token_count = count_total_tokens(messages) response = make_openai_call(GPT_4_O, messages, token_count, - ["summary"], - GEN_QUESTION_TEMPERATURE) + ["summary"], + GEN_QUESTION_TEMPERATURE) messages = [ { @@ -693,15 +699,16 @@ def gen_summary_fill_blanks_exercise(text: str, quantity: int, start_id, difficu { "role": "user", "content": ('Select ' + str(quantity) + ' ' + difficulty + ' difficulty words, it must be words and not ' - 'expressions, from this:\n' + response["summary"]) + 'expressions, from this:\n' + response[ + "summary"]) } ] token_count = count_total_tokens(messages) words_response = make_openai_call(GPT_4_O, messages, token_count, - ["summary"], - GEN_QUESTION_TEMPERATURE) + ["summary"], + GEN_QUESTION_TEMPERATURE) response["words"] = words_response["words"] replaced_summary = replace_first_occurrences_with_placeholders(response["summary"], response["words"], start_id) options_words = add_random_words_and_shuffle(response["words"], 1) @@ -732,18 +739,19 @@ def gen_true_false_not_given_exercise(text: str, quantity: int, start_id, diffic { "role": "user", "content": ( - 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty statements based on the provided text. ' - 'Ensure that your statements accurately represent ' - 'information or inferences from the text, and ' - 'provide a variety of responses, including, at ' - 'least one of each True, False, and Not Given, ' - 'as appropriate.\n\nReference text:\n\n ' + text) + 'Generate ' + str( + quantity) + ' ' + difficulty + ' difficulty statements based on the provided text. ' + 'Ensure that your statements accurately represent ' + 'information or inferences from the text, and ' + 'provide a variety of responses, including, at ' + 'least one of each True, False, and Not Given, ' + 'as appropriate.\n\nReference text:\n\n ' + text) } ] token_count = count_total_tokens(messages) - questions = make_openai_call(GPT_4_O, messages, token_count,["prompts"], + questions = make_openai_call(GPT_4_O, messages, token_count, ["prompts"], GEN_QUESTION_TEMPERATURE)["prompts"] if len(questions) > quantity: questions = remove_excess_questions(questions, len(questions) - quantity) @@ -777,7 +785,7 @@ def gen_write_blanks_exercise(text: str, quantity: int, start_id, difficulty): } ] token_count = count_total_tokens(messages) - questions = make_openai_call(GPT_4_O, messages, token_count,["questions"], + questions = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE)["questions"][:quantity] return { @@ -802,13 +810,14 @@ def gen_paragraph_match_exercise(text: str, quantity: int, start_id): { "role": "user", "content": ( - 'For every paragraph of the list generate a minimum 5 word heading for it. The paragraphs are these: ' + str(paragraphs)) + 'For every paragraph of the list generate a minimum 5 word heading for it. The paragraphs are these: ' + str( + paragraphs)) } ] token_count = count_total_tokens(messages) - headings = make_openai_call(GPT_4_O, messages, token_count,["headings"], + headings = make_openai_call(GPT_4_O, messages, token_count, ["headings"], GEN_QUESTION_TEMPERATURE)["headings"] options = [] @@ -838,6 +847,83 @@ def gen_paragraph_match_exercise(text: str, quantity: int, start_id): } +def gen_idea_match_exercise(text: str, quantity: int, start_id): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"ideas": [ ' + '{"idea": "some idea or opinion", "from": "person, institution whose idea or opinion this is"}, ' + '{"idea": "some other idea or opinion", "from": "person, institution whose idea or opinion this is"}' + ']}') + }, + { + "role": "user", + "content": ( + 'From the text extract ' + str(quantity) + ' ideas, theories, opinions and who they are from. The text: ' + str(text)) + + } + ] + token_count = count_total_tokens(messages) + + ideas = make_openai_call(GPT_4_O, messages, token_count, ["ideas"], GEN_QUESTION_TEMPERATURE)["ideas"] + # options = [ + # { + # "id": "A", + # "sentence": "Cultural appropriation is a term that has gained significant traction in contemporary" + # }, + # { + # "id": "B", + # "sentence": "Historically, cultural appropriation can be traced back to the era of colonialism" + # } + # ] + + # sentences = [ + # { + # "id": 21, + # "sentence": "Concluding Thoughts on Cultural Appropriation", + # "solution": "I" + # }, + # { + # "id": 22, + # "sentence": "Understanding the Concept of Cultural Appropriation", + # "solution": "A" + # } + # ] + return { + "id": str(uuid.uuid4()), + "allowRepetition": False, + "options": build_options(ideas), + "prompt": "Choose the correct heading for paragraphs from the list of headings below.", + "sentences": build_sentences(ideas, start_id), + "type": "matchSentences" + } + +def build_options(ideas): + options = [] + letters = iter(string.ascii_uppercase) + for idea in ideas: + options.append({ + "id": next(letters), + "sentence": idea["from"] + }) + return options + +def build_sentences(ideas, start_id): + sentences = [] + letters = iter(string.ascii_uppercase) + for idea in ideas: + sentences.append({ + "solution": next(letters), + "sentence": idea["idea"] + }) + + random.shuffle(sentences) + for i, sentence in enumerate(sentences, start=start_id): + sentence["id"] = i + return sentences + def assign_letters_to_paragraphs(paragraphs): result = [] letters = iter(string.ascii_uppercase) @@ -861,14 +947,15 @@ def gen_multiple_choice_exercise_listening_conversation(text: str, quantity: int { "role": "user", "content": ( - 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str(n_options) + ' options ' - 'of for this conversation:\n"' + text + '"') + 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str( + n_options) + ' options ' + 'of for this conversation:\n"' + text + '"') } ] token_count = count_total_tokens(messages) - question = make_openai_call(GPT_4_O, messages, token_count,["questions"], GEN_QUESTION_TEMPERATURE) + question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE) return { "id": str(uuid.uuid4()), "prompt": "Select the appropriate option.", @@ -892,14 +979,15 @@ def gen_multiple_choice_exercise_listening_monologue(text: str, quantity: int, s "role": "user", "content": ( 'Generate ' + str( - quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str(n_options) + ' options ' - 'of for this monologue:\n"' + text + '"') + quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str( + n_options) + ' options ' + 'of for this monologue:\n"' + text + '"') } ] token_count = count_total_tokens(messages) - question = make_openai_call(GPT_4_O, messages, token_count,["questions"], GEN_QUESTION_TEMPERATURE) + question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE) return { "id": str(uuid.uuid4()), "prompt": "Select the appropriate option.", @@ -927,7 +1015,7 @@ def gen_write_blanks_questions_exercise_listening_conversation(text: str, quanti ] token_count = count_total_tokens(messages) - questions = make_openai_call(GPT_4_O, messages, token_count,["questions"], + questions = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE)["questions"][:quantity] return { @@ -993,7 +1081,6 @@ def gen_write_blanks_notes_exercise_listening_conversation(text: str, quantity: questions = make_openai_call(GPT_4_O, messages, token_count, ["notes"], GEN_QUESTION_TEMPERATURE)["notes"][:quantity] - formatted_phrases = "\n".join([f"{i + 1}. {phrase}" for i, phrase in enumerate(questions)]) word_messages = [ @@ -1008,7 +1095,7 @@ def gen_write_blanks_notes_exercise_listening_conversation(text: str, quantity: } ] - words = make_openai_call(GPT_4_O, word_messages, token_count,["words"], + words = make_openai_call(GPT_4_O, word_messages, token_count, ["words"], GEN_QUESTION_TEMPERATURE)["words"][:quantity] replaced_notes = replace_first_occurrences_with_placeholders_notes(questions, words, start_id) return { @@ -1149,11 +1236,11 @@ def gen_multiple_choice_level(quantity: int, start_id=1): "role": "system", "content": ( 'You are a helpful assistant designed to output JSON on this format: {"questions": [{"id": "9", "options": ' - '[{"id": "A", "text": ' - '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' - '"Happy"}, {"id": "D", "text": "Jump"}], ' - '"prompt": "Which of the following is a conjunction?", ' - '"solution": "A", "variant": "text"}]}') + '[{"id": "A", "text": ' + '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' + '"Happy"}, {"id": "D", "text": "Jump"}], ' + '"prompt": "Which of the following is a conjunction?", ' + '"solution": "A", "variant": "text"}]}') }, { "role": "user", @@ -1163,8 +1250,8 @@ def gen_multiple_choice_level(quantity: int, start_id=1): token_count = count_total_tokens(messages) question = make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE) + ["questions"], + GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) @@ -1204,6 +1291,7 @@ def replace_exercise_if_exists(all_exams, current_exercise, current_exam, seen_k return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) return current_exercise, seen_keys + def replace_exercise_if_exists_utas(all_exams, current_exercise, current_exam, seen_keys): # Extracting relevant fields for comparison key = (current_exercise['prompt'], tuple(sorted(option['text'] for option in current_exercise['options']))) @@ -1220,7 +1308,8 @@ def replace_exercise_if_exists_utas(all_exams, current_exercise, current_exam, s current_exercise["options"]) for exercise in exam.get("questions", []) ): - return replace_exercise_if_exists_utas(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_level_question(), current_exam, + seen_keys) return current_exercise, seen_keys @@ -1243,8 +1332,8 @@ def generate_single_mc_level_question(): ] token_count = count_total_tokens(messages) - question = make_openai_call(GPT_4_O, messages, token_count,["options"], - GEN_QUESTION_TEMPERATURE) + question = make_openai_call(GPT_4_O, messages, token_count, ["options"], + GEN_QUESTION_TEMPERATURE) return question @@ -1273,11 +1362,11 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams "role": "system", "content": ( 'You are a helpful assistant designed to output JSON on this format: {"questions": [{"id": "9", "options": ' - '[{"id": "A", "text": ' - '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' - '"Happy"}, {"id": "D", "text": "Jump"}], ' - '"prompt": "Which of the following is a conjunction?", ' - '"solution": "A", "variant": "text"}]}') + '[{"id": "A", "text": ' + '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' + '"Happy"}, {"id": "D", "text": "Jump"}], ' + '"prompt": "Which of the following is a conjunction?", ' + '"solution": "A", "variant": "text"}]}') }, { "role": "user", @@ -1287,8 +1376,8 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams token_count = count_total_tokens(messages) question = make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE) + ["questions"], + GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) @@ -1296,8 +1385,8 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams seen_keys = set() for i in range(len(question["questions"])): question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_exams, question["questions"][i], - question, - seen_keys) + question, + seen_keys) return fix_exercise_ids(question, start_id) @@ -1331,13 +1420,14 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): ] } - gen_multiple_choice_for_text = 'Generate ' + str(quantity) + (' multiple choice questions of 4 options for an english ' - 'level exam, some easy questions, some intermediate ' - 'questions and some advanced questions.Ensure that ' - 'the questions cover a range of topics such as verb ' - 'tense, subject-verb agreement, pronoun usage, ' - 'sentence structure, and punctuation. Make sure ' - 'every question only has 1 correct answer.') + gen_multiple_choice_for_text = 'Generate ' + str(quantity) + ( + ' multiple choice questions of 4 options for an english ' + 'level exam, some easy questions, some intermediate ' + 'questions and some advanced questions.Ensure that ' + 'the questions cover a range of topics such as verb ' + 'tense, subject-verb agreement, pronoun usage, ' + 'sentence structure, and punctuation. Make sure ' + 'every question only has 1 correct answer.') messages = [ { @@ -1360,14 +1450,15 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): token_count = count_total_tokens(messages) question = make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE) + ["questions"], + GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) else: return fix_exercise_ids(question, start_id)["questions"] + def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=random.choice(mti_topics)): json_format = { "question": { @@ -1406,10 +1497,11 @@ def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=ran { "role": "user", "content": ( - 'From the generated text choose ' + str(quantity) + ' words (cannot be sequential words) to replace ' - 'once with {{id}} where id starts on ' + str(start_id) + ' and is ' - 'incremented for each word. The ids must be ordered throughout the text and the words must be ' - 'replaced only once. Put the removed words and respective ids on the words array of the json in the correct order.') + 'From the generated text choose ' + str( + quantity) + ' words (cannot be sequential words) to replace ' + 'once with {{id}} where id starts on ' + str(start_id) + ' and is ' + 'incremented for each word. The ids must be ordered throughout the text and the words must be ' + 'replaced only once. Put the removed words and respective ids on the words array of the json in the correct order.') } ] @@ -1420,14 +1512,14 @@ def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=ran return question["question"] -def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic=random.choice(mti_topics)): +def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic=random.choice(mti_topics)): passage = generate_reading_passage_1_text(topic) short_answer = gen_short_answer_utas(passage["text"], start_id, sa_quantity) - mc_exercises = gen_text_multiple_choice_utas(passage["text"], start_id+sa_quantity, mc_quantity) + mc_exercises = gen_text_multiple_choice_utas(passage["text"], start_id + sa_quantity, mc_quantity) return { "exercises": { - "shortAnswer":short_answer, + "shortAnswer": short_answer, "multipleChoice": mc_exercises, }, "text": { @@ -1436,6 +1528,7 @@ def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic } } + def gen_short_answer_utas(text: str, start_id: int, sa_quantity: int): json_format = {"questions": [{"id": 1, "question": "question", "possible_answers": ["answer_1", "answer_2"]}]} @@ -1458,8 +1551,10 @@ def gen_short_answer_utas(text: str, start_id: int, sa_quantity: int): token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE)["questions"] + ["questions"], + GEN_QUESTION_TEMPERATURE)["questions"] + + def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): json_format = { "questions": [ @@ -1497,7 +1592,8 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): }, { "role": "user", - "content": 'Generate ' + str(mc_quantity) + ' multiple choice questions of 4 options for this text:\n' + text + "content": 'Generate ' + str( + mc_quantity) + ' multiple choice questions of 4 options for this text:\n' + text }, { "role": "user", @@ -1513,4 +1609,4 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): if len(question["questions"]) != mc_quantity: return gen_multiple_choice_level(mc_quantity, start_id) else: - return fix_exercise_ids(question, start_id)["questions"] \ No newline at end of file + return fix_exercise_ids(question, start_id)["questions"] From 4c41942dfea01352b62311d22610bf6b9c7510ac Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 18 Jul 2024 23:20:06 +0100 Subject: [PATCH 18/44] Added new ideaMatch exercise type. --- helper/constants.py | 1 + helper/exercises.py | 292 +++++++++++++++++++++++++++++--------------- 2 files changed, 195 insertions(+), 98 deletions(-) diff --git a/helper/constants.py b/helper/constants.py index 883ae9a..fdd45e4 100644 --- a/helper/constants.py +++ b/helper/constants.py @@ -18,6 +18,7 @@ GEN_FIELDS = ['topic'] GEN_TEXT_FIELDS = ['title'] LISTENING_GEN_FIELDS = ['transcript', 'exercise'] READING_EXERCISE_TYPES = ['fillBlanks', 'writeBlanks', 'trueFalse', 'paragraphMatch'] +READING_3_EXERCISE_TYPES = ['fillBlanks', 'writeBlanks', 'trueFalse', 'paragraphMatch', 'ideaMatch'] LISTENING_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksForm'] LISTENING_1_EXERCISE_TYPES = ['multipleChoice', 'writeBlanksQuestions', 'writeBlanksFill', 'writeBlanksFill', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm', 'writeBlanksForm'] diff --git a/helper/exercises.py b/helper/exercises.py index c3022bd..8e299e4 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -7,7 +7,6 @@ import uuid import nltk from wonderwords import RandomWord -from helper.api_messages import QuestionType from helper.constants import * from helper.firebase_helper import get_all from helper.openai_interface import make_openai_call, count_total_tokens @@ -243,6 +242,7 @@ def build_write_blanks_solutions_listening(words: [], start_id): ) return solutions + def get_perfect_answer(question: str, size: int): messages = [ { @@ -278,20 +278,20 @@ def generate_reading_passage_1_text(topic: str): "role": "user", "content": ( 'Generate an extensive text for IELTS Reading Passage 1, of at least 800 words, on the topic ' - 'of "' + topic + '". The passage should offer ' - 'a substantial amount of information, ' - 'analysis, or narrative relevant to the chosen ' - 'subject matter. This text passage aims to ' - 'serve as the primary reading section of an ' - 'IELTS test, providing an in-depth and ' - 'comprehensive exploration of the topic. ' - 'Make sure that the generated text does not ' - 'contain forbidden subjects in muslim countries.') + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') }, { "role": "system", - "content": ('The generated text should be fairly easy to understand.') + "content": ('The generated text should be fairly easy to understand and have multiple paragraphs.') }, ] token_count = count_total_tokens(messages) @@ -310,25 +310,26 @@ def generate_reading_passage_2_text(topic: str): "role": "user", "content": ( 'Generate an extensive text for IELTS Reading Passage 2, of at least 800 words, on the topic ' - 'of "' + topic + '". The passage should offer ' - 'a substantial amount of information, ' - 'analysis, or narrative relevant to the chosen ' - 'subject matter. This text passage aims to ' - 'serve as the primary reading section of an ' - 'IELTS test, providing an in-depth and ' - 'comprehensive exploration of the topic. ' - 'Make sure that the generated text does not ' - 'contain forbidden subjects in muslim countries.') + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') }, { "role": "system", - "content": ('The generated text should be fairly hard to understand.') + "content": ('The generated text should be fairly hard to understand and have multiple paragraphs.') }, ] token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) + def generate_reading_passage_3_text(topic: str): messages = [ { @@ -341,21 +342,22 @@ def generate_reading_passage_3_text(topic: str): "role": "user", "content": ( 'Generate an extensive text for IELTS Reading Passage 3, of at least 800 words, on the topic ' - 'of "' + topic + '". The passage should offer ' - 'a substantial amount of information, ' - 'analysis, or narrative relevant to the chosen ' - 'subject matter. This text passage aims to ' - 'serve as the primary reading section of an ' - 'IELTS test, providing an in-depth and ' - 'comprehensive exploration of the topic. ' - 'Make sure that the generated text does not ' - 'contain forbidden subjects in muslim countries.') + 'of "' + topic + '". The passage should offer ' + 'a substantial amount of information, ' + 'analysis, or narrative relevant to the chosen ' + 'subject matter. This text passage aims to ' + 'serve as the primary reading section of an ' + 'IELTS test, providing an in-depth and ' + 'comprehensive exploration of the topic. ' + 'Make sure that the generated text does not ' + 'contain forbidden subjects in muslim countries.') }, { "role": "system", "content": ('The generated text should be very hard to understand and include different points, theories, ' - 'subtle differences of opinions from people over the specified topic .') + 'subtle differences of opinions from people, correctly sourced to the person who said it, ' + 'over the specified topic and have multiple paragraphs.') }, ] token_count = count_total_tokens(messages) @@ -464,8 +466,8 @@ def generate_listening_3_conversation(topic: str): "content": ( 'Compose an authentic and elaborate conversation between up to four individuals in the everyday ' 'social context of "' + topic + '". Please include random names and genders for the characters in your dialogue. ' - 'Make sure that the generated conversation does not contain forbidden subjects in ' - 'muslim countries.') + 'Make sure that the generated conversation does not contain forbidden subjects in ' + 'muslim countries.') } ] @@ -507,7 +509,7 @@ def generate_listening_4_monologue(topic: str): "content": ( 'Generate a comprehensive and complex monologue on the academic subject ' 'of: "' + topic + '". Make sure that the generated monologue does not contain forbidden subjects in ' - 'muslim countries.') + 'muslim countries.') } ] @@ -547,6 +549,10 @@ def generate_reading_exercises(passage: str, req_exercises: list, number_of_exer question = gen_paragraph_match_exercise(passage, number_of_exercises, start_id) exercises.append(question) print("Added paragraph match: " + str(question)) + elif req_exercise == "ideaMatch": + question = gen_idea_match_exercise(passage, number_of_exercises, start_id) + exercises.append(question) + print("Added idea match: " + str(question)) start_id = start_id + number_of_exercises @@ -673,15 +679,15 @@ def gen_summary_fill_blanks_exercise(text: str, quantity: int, start_id, difficu }, { "role": "user", - "content": ('Summarize this text: "'+ text + '"') + "content": ('Summarize this text: "' + text + '"') } ] token_count = count_total_tokens(messages) response = make_openai_call(GPT_4_O, messages, token_count, - ["summary"], - GEN_QUESTION_TEMPERATURE) + ["summary"], + GEN_QUESTION_TEMPERATURE) messages = [ { @@ -693,15 +699,16 @@ def gen_summary_fill_blanks_exercise(text: str, quantity: int, start_id, difficu { "role": "user", "content": ('Select ' + str(quantity) + ' ' + difficulty + ' difficulty words, it must be words and not ' - 'expressions, from this:\n' + response["summary"]) + 'expressions, from this:\n' + response[ + "summary"]) } ] token_count = count_total_tokens(messages) words_response = make_openai_call(GPT_4_O, messages, token_count, - ["summary"], - GEN_QUESTION_TEMPERATURE) + ["summary"], + GEN_QUESTION_TEMPERATURE) response["words"] = words_response["words"] replaced_summary = replace_first_occurrences_with_placeholders(response["summary"], response["words"], start_id) options_words = add_random_words_and_shuffle(response["words"], 1) @@ -732,18 +739,19 @@ def gen_true_false_not_given_exercise(text: str, quantity: int, start_id, diffic { "role": "user", "content": ( - 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty statements based on the provided text. ' - 'Ensure that your statements accurately represent ' - 'information or inferences from the text, and ' - 'provide a variety of responses, including, at ' - 'least one of each True, False, and Not Given, ' - 'as appropriate.\n\nReference text:\n\n ' + text) + 'Generate ' + str( + quantity) + ' ' + difficulty + ' difficulty statements based on the provided text. ' + 'Ensure that your statements accurately represent ' + 'information or inferences from the text, and ' + 'provide a variety of responses, including, at ' + 'least one of each True, False, and Not Given, ' + 'as appropriate.\n\nReference text:\n\n ' + text) } ] token_count = count_total_tokens(messages) - questions = make_openai_call(GPT_4_O, messages, token_count,["prompts"], + questions = make_openai_call(GPT_4_O, messages, token_count, ["prompts"], GEN_QUESTION_TEMPERATURE)["prompts"] if len(questions) > quantity: questions = remove_excess_questions(questions, len(questions) - quantity) @@ -777,7 +785,7 @@ def gen_write_blanks_exercise(text: str, quantity: int, start_id, difficulty): } ] token_count = count_total_tokens(messages) - questions = make_openai_call(GPT_4_O, messages, token_count,["questions"], + questions = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE)["questions"][:quantity] return { @@ -802,13 +810,14 @@ def gen_paragraph_match_exercise(text: str, quantity: int, start_id): { "role": "user", "content": ( - 'For every paragraph of the list generate a minimum 5 word heading for it. The paragraphs are these: ' + str(paragraphs)) + 'For every paragraph of the list generate a minimum 5 word heading for it. The paragraphs are these: ' + str( + paragraphs)) } ] token_count = count_total_tokens(messages) - headings = make_openai_call(GPT_4_O, messages, token_count,["headings"], + headings = make_openai_call(GPT_4_O, messages, token_count, ["headings"], GEN_QUESTION_TEMPERATURE)["headings"] options = [] @@ -838,6 +847,83 @@ def gen_paragraph_match_exercise(text: str, quantity: int, start_id): } +def gen_idea_match_exercise(text: str, quantity: int, start_id): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"ideas": [ ' + '{"idea": "some idea or opinion", "from": "person, institution whose idea or opinion this is"}, ' + '{"idea": "some other idea or opinion", "from": "person, institution whose idea or opinion this is"}' + ']}') + }, + { + "role": "user", + "content": ( + 'From the text extract ' + str(quantity) + ' ideas, theories, opinions and who they are from. The text: ' + str(text)) + + } + ] + token_count = count_total_tokens(messages) + + ideas = make_openai_call(GPT_4_O, messages, token_count, ["ideas"], GEN_QUESTION_TEMPERATURE)["ideas"] + # options = [ + # { + # "id": "A", + # "sentence": "Cultural appropriation is a term that has gained significant traction in contemporary" + # }, + # { + # "id": "B", + # "sentence": "Historically, cultural appropriation can be traced back to the era of colonialism" + # } + # ] + + # sentences = [ + # { + # "id": 21, + # "sentence": "Concluding Thoughts on Cultural Appropriation", + # "solution": "I" + # }, + # { + # "id": 22, + # "sentence": "Understanding the Concept of Cultural Appropriation", + # "solution": "A" + # } + # ] + return { + "id": str(uuid.uuid4()), + "allowRepetition": False, + "options": build_options(ideas), + "prompt": "Choose the correct author for the ideas/opinions from the list of authors below.", + "sentences": build_sentences(ideas, start_id), + "type": "matchSentences" + } + +def build_options(ideas): + options = [] + letters = iter(string.ascii_uppercase) + for idea in ideas: + options.append({ + "id": next(letters), + "sentence": idea["from"] + }) + return options + +def build_sentences(ideas, start_id): + sentences = [] + letters = iter(string.ascii_uppercase) + for idea in ideas: + sentences.append({ + "solution": next(letters), + "sentence": idea["idea"] + }) + + random.shuffle(sentences) + for i, sentence in enumerate(sentences, start=start_id): + sentence["id"] = i + return sentences + def assign_letters_to_paragraphs(paragraphs): result = [] letters = iter(string.ascii_uppercase) @@ -861,14 +947,15 @@ def gen_multiple_choice_exercise_listening_conversation(text: str, quantity: int { "role": "user", "content": ( - 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str(n_options) + ' options ' - 'of for this conversation:\n"' + text + '"') + 'Generate ' + str(quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str( + n_options) + ' options ' + 'of for this conversation:\n"' + text + '"') } ] token_count = count_total_tokens(messages) - question = make_openai_call(GPT_4_O, messages, token_count,["questions"], GEN_QUESTION_TEMPERATURE) + question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE) return { "id": str(uuid.uuid4()), "prompt": "Select the appropriate option.", @@ -892,14 +979,15 @@ def gen_multiple_choice_exercise_listening_monologue(text: str, quantity: int, s "role": "user", "content": ( 'Generate ' + str( - quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str(n_options) + ' options ' - 'of for this monologue:\n"' + text + '"') + quantity) + ' ' + difficulty + ' difficulty multiple choice questions of ' + str( + n_options) + ' options ' + 'of for this monologue:\n"' + text + '"') } ] token_count = count_total_tokens(messages) - question = make_openai_call(GPT_4_O, messages, token_count,["questions"], GEN_QUESTION_TEMPERATURE) + question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE) return { "id": str(uuid.uuid4()), "prompt": "Select the appropriate option.", @@ -927,7 +1015,7 @@ def gen_write_blanks_questions_exercise_listening_conversation(text: str, quanti ] token_count = count_total_tokens(messages) - questions = make_openai_call(GPT_4_O, messages, token_count,["questions"], + questions = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE)["questions"][:quantity] return { @@ -993,7 +1081,6 @@ def gen_write_blanks_notes_exercise_listening_conversation(text: str, quantity: questions = make_openai_call(GPT_4_O, messages, token_count, ["notes"], GEN_QUESTION_TEMPERATURE)["notes"][:quantity] - formatted_phrases = "\n".join([f"{i + 1}. {phrase}" for i, phrase in enumerate(questions)]) word_messages = [ @@ -1008,7 +1095,7 @@ def gen_write_blanks_notes_exercise_listening_conversation(text: str, quantity: } ] - words = make_openai_call(GPT_4_O, word_messages, token_count,["words"], + words = make_openai_call(GPT_4_O, word_messages, token_count, ["words"], GEN_QUESTION_TEMPERATURE)["words"][:quantity] replaced_notes = replace_first_occurrences_with_placeholders_notes(questions, words, start_id) return { @@ -1149,11 +1236,11 @@ def gen_multiple_choice_level(quantity: int, start_id=1): "role": "system", "content": ( 'You are a helpful assistant designed to output JSON on this format: {"questions": [{"id": "9", "options": ' - '[{"id": "A", "text": ' - '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' - '"Happy"}, {"id": "D", "text": "Jump"}], ' - '"prompt": "Which of the following is a conjunction?", ' - '"solution": "A", "variant": "text"}]}') + '[{"id": "A", "text": ' + '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' + '"Happy"}, {"id": "D", "text": "Jump"}], ' + '"prompt": "Which of the following is a conjunction?", ' + '"solution": "A", "variant": "text"}]}') }, { "role": "user", @@ -1163,8 +1250,8 @@ def gen_multiple_choice_level(quantity: int, start_id=1): token_count = count_total_tokens(messages) question = make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE) + ["questions"], + GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) @@ -1204,6 +1291,7 @@ def replace_exercise_if_exists(all_exams, current_exercise, current_exam, seen_k return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) return current_exercise, seen_keys + def replace_exercise_if_exists_utas(all_exams, current_exercise, current_exam, seen_keys): # Extracting relevant fields for comparison key = (current_exercise['prompt'], tuple(sorted(option['text'] for option in current_exercise['options']))) @@ -1220,7 +1308,8 @@ def replace_exercise_if_exists_utas(all_exams, current_exercise, current_exam, s current_exercise["options"]) for exercise in exam.get("questions", []) ): - return replace_exercise_if_exists_utas(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_level_question(), current_exam, + seen_keys) return current_exercise, seen_keys @@ -1243,8 +1332,8 @@ def generate_single_mc_level_question(): ] token_count = count_total_tokens(messages) - question = make_openai_call(GPT_4_O, messages, token_count,["options"], - GEN_QUESTION_TEMPERATURE) + question = make_openai_call(GPT_4_O, messages, token_count, ["options"], + GEN_QUESTION_TEMPERATURE) return question @@ -1273,11 +1362,11 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams "role": "system", "content": ( 'You are a helpful assistant designed to output JSON on this format: {"questions": [{"id": "9", "options": ' - '[{"id": "A", "text": ' - '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' - '"Happy"}, {"id": "D", "text": "Jump"}], ' - '"prompt": "Which of the following is a conjunction?", ' - '"solution": "A", "variant": "text"}]}') + '[{"id": "A", "text": ' + '"And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' + '"Happy"}, {"id": "D", "text": "Jump"}], ' + '"prompt": "Which of the following is a conjunction?", ' + '"solution": "A", "variant": "text"}]}') }, { "role": "user", @@ -1287,8 +1376,8 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams token_count = count_total_tokens(messages) question = make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE) + ["questions"], + GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) @@ -1296,8 +1385,8 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams seen_keys = set() for i in range(len(question["questions"])): question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_exams, question["questions"][i], - question, - seen_keys) + question, + seen_keys) return fix_exercise_ids(question, start_id) @@ -1331,13 +1420,14 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): ] } - gen_multiple_choice_for_text = 'Generate ' + str(quantity) + (' multiple choice questions of 4 options for an english ' - 'level exam, some easy questions, some intermediate ' - 'questions and some advanced questions.Ensure that ' - 'the questions cover a range of topics such as verb ' - 'tense, subject-verb agreement, pronoun usage, ' - 'sentence structure, and punctuation. Make sure ' - 'every question only has 1 correct answer.') + gen_multiple_choice_for_text = 'Generate ' + str(quantity) + ( + ' multiple choice questions of 4 options for an english ' + 'level exam, some easy questions, some intermediate ' + 'questions and some advanced questions.Ensure that ' + 'the questions cover a range of topics such as verb ' + 'tense, subject-verb agreement, pronoun usage, ' + 'sentence structure, and punctuation. Make sure ' + 'every question only has 1 correct answer.') messages = [ { @@ -1360,14 +1450,15 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): token_count = count_total_tokens(messages) question = make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE) + ["questions"], + GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) else: return fix_exercise_ids(question, start_id)["questions"] + def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=random.choice(mti_topics)): json_format = { "question": { @@ -1406,10 +1497,11 @@ def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=ran { "role": "user", "content": ( - 'From the generated text choose ' + str(quantity) + ' words (cannot be sequential words) to replace ' - 'once with {{id}} where id starts on ' + str(start_id) + ' and is ' - 'incremented for each word. The ids must be ordered throughout the text and the words must be ' - 'replaced only once. Put the removed words and respective ids on the words array of the json in the correct order.') + 'From the generated text choose ' + str( + quantity) + ' words (cannot be sequential words) to replace ' + 'once with {{id}} where id starts on ' + str(start_id) + ' and is ' + 'incremented for each word. The ids must be ordered throughout the text and the words must be ' + 'replaced only once. Put the removed words and respective ids on the words array of the json in the correct order.') } ] @@ -1420,14 +1512,14 @@ def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=ran return question["question"] -def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic=random.choice(mti_topics)): +def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic=random.choice(mti_topics)): passage = generate_reading_passage_1_text(topic) short_answer = gen_short_answer_utas(passage["text"], start_id, sa_quantity) - mc_exercises = gen_text_multiple_choice_utas(passage["text"], start_id+sa_quantity, mc_quantity) + mc_exercises = gen_text_multiple_choice_utas(passage["text"], start_id + sa_quantity, mc_quantity) return { "exercises": { - "shortAnswer":short_answer, + "shortAnswer": short_answer, "multipleChoice": mc_exercises, }, "text": { @@ -1436,6 +1528,7 @@ def gen_reading_passage_utas(start_id, sa_quantity: int, mc_quantity: int, topic } } + def gen_short_answer_utas(text: str, start_id: int, sa_quantity: int): json_format = {"questions": [{"id": 1, "question": "question", "possible_answers": ["answer_1", "answer_2"]}]} @@ -1458,8 +1551,10 @@ def gen_short_answer_utas(text: str, start_id: int, sa_quantity: int): token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, - ["questions"], - GEN_QUESTION_TEMPERATURE)["questions"] + ["questions"], + GEN_QUESTION_TEMPERATURE)["questions"] + + def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): json_format = { "questions": [ @@ -1497,7 +1592,8 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): }, { "role": "user", - "content": 'Generate ' + str(mc_quantity) + ' multiple choice questions of 4 options for this text:\n' + text + "content": 'Generate ' + str( + mc_quantity) + ' multiple choice questions of 4 options for this text:\n' + text }, { "role": "user", @@ -1513,4 +1609,4 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): if len(question["questions"]) != mc_quantity: return gen_multiple_choice_level(mc_quantity, start_id) else: - return fix_exercise_ids(question, start_id)["questions"] \ No newline at end of file + return fix_exercise_ids(question, start_id)["questions"] From 1ecda04c6b96bf5cbe5cab8bf1040b415ab4b058 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Mon, 22 Jul 2024 14:54:01 +0100 Subject: [PATCH 19/44] Fix array index out of bounds. --- helper/exercises.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/helper/exercises.py b/helper/exercises.py index dd3b939..c516e6a 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -1260,13 +1260,14 @@ def replace_exercise_if_exists(all_exams, current_exercise, current_exam, seen_k for exam in all_exams: exam_dict = exam.to_dict() exercise_dict = exam_dict.get("parts", [])[0] - if any( - exercise["prompt"] == current_exercise["prompt"] and - any(exercise["options"][0]["text"] == current_option["text"] for current_option in - current_exercise["options"]) - for exercise in exercise_dict.get("exercises", [])[0]["questions"] - ): - return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) + if len(exercise_dict.get("exercises", [])) > 0: + if any( + exercise["prompt"] == current_exercise["prompt"] and + any(exercise["options"][0]["text"] == current_option["text"] for current_option in + current_exercise["options"]) + for exercise in exercise_dict.get("exercises", [])[0]["questions"] + ): + return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) return current_exercise, seen_keys From bf9251eebb2fc173a66b7fc667f75ddb398447d4 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Mon, 22 Jul 2024 15:29:01 +0100 Subject: [PATCH 20/44] Fix array index out of bounds. --- helper/exercises.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/helper/exercises.py b/helper/exercises.py index c516e6a..81fa7a5 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -1259,15 +1259,16 @@ def replace_exercise_if_exists(all_exams, current_exercise, current_exam, seen_k for exam in all_exams: exam_dict = exam.to_dict() - exercise_dict = exam_dict.get("parts", [])[0] - if len(exercise_dict.get("exercises", [])) > 0: - if any( - exercise["prompt"] == current_exercise["prompt"] and - any(exercise["options"][0]["text"] == current_option["text"] for current_option in - current_exercise["options"]) - for exercise in exercise_dict.get("exercises", [])[0]["questions"] - ): - return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) + if len(exam_dict.get("parts", [])) > 0: + exercise_dict = exam_dict.get("parts", [])[0] + if len(exercise_dict.get("exercises", [])) > 0: + if any( + exercise["prompt"] == current_exercise["prompt"] and + any(exercise["options"][0]["text"] == current_option["text"] for current_option in + current_exercise["options"]) + for exercise in exercise_dict.get("exercises", [])[0]["questions"] + ): + return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) return current_exercise, seen_keys From 4776f242295c647741d54adaad076b9085cecd9a Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 23 Jul 2024 13:22:52 +0100 Subject: [PATCH 21/44] Fix speaking grading overall. --- app.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app.py b/app.py index 24ecb52..de47607 100644 --- a/app.py +++ b/app.py @@ -660,11 +660,7 @@ def grade_speaking_task_1(): response['transcript_' + str(i)] = answer response['fixed_text_' + str(i)] = get_speaking_corrections(answer) - if response["overall"] == "0.0" or response["overall"] == 0.0: - response["overall"] = round((response["task_response"]["Fluency and Coherence"] + - response["task_response"]["Lexical Resource"] + response["task_response"][ - "Grammatical Range and Accuracy"] + response["task_response"][ - "Pronunciation"]) / 4, 1) + response["overall"] = fix_speaking_overall(response["overall"], response["task_response"]) logging.info("POST - speaking_task_1 - " + str(request_id) + " - Final response: " + str(response)) return response @@ -830,11 +826,7 @@ def grade_speaking_task_2(): response['fixed_text'] = get_speaking_corrections(answer) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Fixed text: " + response['fixed_text']) - if response["overall"] == "0.0" or response["overall"] == 0.0: - response["overall"] = round((response["task_response"]["Fluency and Coherence"] + - response["task_response"]["Lexical Resource"] + response["task_response"][ - "Grammatical Range and Accuracy"] + response["task_response"][ - "Pronunciation"]) / 4, 1) + response["overall"] = fix_speaking_overall(response["overall"], response["task_response"]) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Final response: " + str(response)) return response @@ -1121,16 +1113,24 @@ def grade_speaking_task_3(): for i, answer in enumerate(text_answers, start=1): response['transcript_' + str(i)] = answer response['fixed_text_' + str(i)] = get_speaking_corrections(answer) - if response["overall"] == "0.0" or response["overall"] == 0.0: - response["overall"] = round((response["task_response"]["Fluency and Coherence"] + response["task_response"][ - "Lexical Resource"] + response["task_response"]["Grammatical Range and Accuracy"] + - response["task_response"]["Pronunciation"]) / 4, 1) + response["overall"] = fix_speaking_overall(response["overall"], response["task_response"]) logging.info("POST - speaking_task_3 - " + str(request_id) + " - Final response: " + str(response)) return response except Exception as e: return str(e), 400 +def fix_speaking_overall(overall: float, task_response: dict): + grades = [category["grade"] for category in task_response.values()] + + if overall > max(grades) or overall < min(grades): + total_sum = sum(grades) + average = total_sum / len(grades) + rounded_average = round(average, 0) + return rounded_average + + return overall + @app.route('/speaking', methods=['POST']) @jwt_required() def save_speaking(): From 9be9bfce0e6d3b30ecf30edb4289a4775862270c Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Wed, 24 Jul 2024 19:58:53 +0100 Subject: [PATCH 22/44] Add endpoint for custom level exams. --- app.py | 49 ++++++++++++++++++++++++++++++++ helper/exercises.py | 69 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index de47607..88af278 100644 --- a/app.py +++ b/app.py @@ -1481,6 +1481,55 @@ def get_level_utas(): except Exception as e: return str(e) +from enum import Enum + +class CustomLevelExerciseTypes(Enum): + MULTIPLE_CHOICE_4 = "multiple_choice_4" + MULTIPLE_CHOICE_BLANK_SPACE = "multiple_choice_blank_space" + MULTIPLE_CHOICE_UNDERLINED = "multiple_choice_underlined" + BLANK_SPACE_TEXT = "blank_space_text" + READING_PASSAGE_UTAS = "reading_passage_utas" + +@app.route('/custom_level', methods=['GET']) +@jwt_required() +def get_custom_level(): + nr_exercises = int(request.args.get('nr_exercises')) + + exercise_id = 1 + response = { + "exercises": {}, + "module": "level" + } + for i in range(1, nr_exercises + 1, 1): + exercise_type = request.args.get('exercise_' + str(i) + '_type') + exercise_qty = int(request.args.get('exercise_' + str(i) + '_qty', -1)) + exercise_topic = request.args.get('exercise_' + str(i) + '_topic') + exercise_text_size = int(request.args.get('exercise_' + str(i) + '_text_size', -1)) + exercise_sa_qty = int(request.args.get('exercise_' + str(i) + '_sa_qty', -1)) + exercise_mc_qty = int(request.args.get('exercise_' + str(i) + '_mc_qty', -1)) + + if exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_4.value: + response["exercises"]["exercise_" + str(i)] = generate_level_mc(exercise_id, exercise_qty) + response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" + exercise_id = exercise_id + exercise_qty + elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_BLANK_SPACE.value: + response["exercises"]["exercise_" + str(i)] = gen_multiple_choice_blank_space_utas(exercise_qty, exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" + exercise_id = exercise_id + exercise_qty + elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_UNDERLINED.value: + response["exercises"]["exercise_" + str(i)] = gen_multiple_choice_underlined_utas(exercise_qty, exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" + exercise_id = exercise_id + exercise_qty + elif exercise_type == CustomLevelExerciseTypes.BLANK_SPACE_TEXT.value: + response["exercises"]["exercise_" + str(i)] = gen_blank_space_text_utas(exercise_qty, exercise_id, exercise_text_size) + response["exercises"]["exercise_" + str(i)]["type"] = "blankSpaceText" + exercise_id = exercise_id + exercise_qty + elif exercise_type == CustomLevelExerciseTypes.READING_PASSAGE_UTAS.value: + response["exercises"]["exercise_" + str(i)] = gen_reading_passage_utas(exercise_id, exercise_sa_qty, exercise_mc_qty, exercise_topic) + response["exercises"]["exercise_" + str(i)]["type"] = "readingExercises" + exercise_id = exercise_id + exercise_qty + + return response @app.route('/fetch_tips', methods=['POST']) @jwt_required() diff --git a/helper/exercises.py b/helper/exercises.py index 81fa7a5..428ebc3 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -1330,7 +1330,7 @@ def parse_conversation(conversation_data): return "\n".join(readable_text) -def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams): +def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams=None): gen_multiple_choice_for_text = "Generate " + str( quantity) + " multiple choice blank space questions of 4 options for an english level exam, some easy questions, some intermediate " \ "questions and some advanced questions. Ensure that the questions cover a range of topics such as " \ @@ -1362,11 +1362,12 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) else: - seen_keys = set() - for i in range(len(question["questions"])): - question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_exams, question["questions"][i], - question, - seen_keys) + if all_exams is not None: + seen_keys = set() + for i in range(len(question["questions"])): + question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_exams, question["questions"][i], + question, + seen_keys) return fix_exercise_ids(question, start_id) @@ -1436,7 +1437,7 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) else: - return fix_exercise_ids(question, start_id)["questions"] + return fix_exercise_ids(question, start_id) def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=random.choice(mti_topics)): @@ -1590,3 +1591,57 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): return gen_multiple_choice_level(mc_quantity, start_id) else: return fix_exercise_ids(question, start_id)["questions"] + + +def generate_level_mc(start_id: int, quantity: int): + json_format = { + "questions": [ + { + "id": "9", + "options": [ + { + "id": "A", + "text": "a" + }, + { + "id": "B", + "text": "b" + }, + { + "id": "C", + "text": "c" + }, + { + "id": "D", + "text": "d" + } + ], + "prompt": "prompt", + "solution": "A", + "variant": "text" + } + ] + } + + messages = [ + { + "role": "system", + "content": 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format) + }, + { + "role": "user", + "content": ('Generate ' + str(quantity) + ' multiple choice question of 4 options for an english level ' + 'exam, it can be easy, intermediate or advanced.') + + }, + { + "role": "user", + "content": 'Make sure every question only has 1 correct answer.' + } + ] + token_count = count_total_tokens(messages) + + question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], + GEN_QUESTION_TEMPERATURE) + + return fix_exercise_ids(question, start_id) From ca12ad11610366b236527cc3fa23b4e9f8d54bdd Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Thu, 25 Jul 2024 16:55:42 +0100 Subject: [PATCH 23/44] Used main as base branch in the last time --- .env | 5 +++-- .gitignore | 3 ++- .idea/.gitignore | 8 -------- .idea/ielts-be.iml | 20 +++++------------- .idea/misc.xml | 8 +++++++- .idea/vcs.xml | 2 +- app.py | 9 ++++++++ helper/gpt_zero.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ helper/heygen_api.py | 32 +++++++++++++++-------------- 9 files changed, 93 insertions(+), 43 deletions(-) delete mode 100644 .idea/.gitignore create mode 100644 helper/gpt_zero.py diff --git a/.env b/.env index 900cd02..efadb83 100644 --- a/.env +++ b/.env @@ -1,5 +1,6 @@ OPENAI_API_KEY=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 JWT_SECRET_KEY=6e9c124ba92e8814719dcb0f21200c8aa4d0f119a994ac5e06eb90a366c83ab2 JWT_TEST_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.Emrs2D3BmMP4b3zMjw0fJTPeyMwWEBDbxx2vvaWguO0 -GOOGLE_APPLICATION_CREDENTIALS=firebase-configs/storied-phalanx-349916.json -HEY_GEN_TOKEN=MjY4MDE0MjdjZmNhNDFmYTlhZGRkNmI3MGFlMzYwZDItMTY5NTExNzY3MA== \ No newline at end of file +GOOGLE_APPLICATION_CREDENTIALS=firebase-configs/test_firebase.json +HEY_GEN_TOKEN=MjY4MDE0MjdjZmNhNDFmYTlhZGRkNmI3MGFlMzYwZDItMTY5NTExNzY3MA== +GPT_ZERO_API_KEY=0195b9bb24c5439899f71230809c74af diff --git a/.gitignore b/.gitignore index b8f579b..e7f296a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ .idea .env -.DS_Store \ No newline at end of file +.DS_Store +/firebase-configs/test_firebase.json diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/ielts-be.iml b/.idea/ielts-be.iml index 7af039d..2b859b5 100644 --- a/.idea/ielts-be.iml +++ b/.idea/ielts-be.iml @@ -1,24 +1,14 @@ - - - + - + - - - - + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index d56657a..6601cfb 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,10 @@ - + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/app.py b/app.py index 88af278..07d324a 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,7 @@ from helper.heygen_api import create_video, create_videos_and_save_to_db from helper.openai_interface import * from helper.question_templates import * from helper.speech_to_text_helper import * +from helper.gpt_zero import GPTZero from heygen.AvatarEnum import AvatarEnum load_dotenv() @@ -30,6 +31,8 @@ FIREBASE_BUCKET = os.getenv('FIREBASE_BUCKET') firebase_admin.initialize_app(cred) +gpt_zero = GPTZero(os.getenv('GPT_ZERO_API_KEY')) + thread_event = threading.Event() # Configure logging @@ -309,6 +312,9 @@ def grade_writing_task_1(): response["perfect_answer"] = get_perfect_answer(question, 150)["perfect_answer"] response["overall"] = fix_writing_overall(response["overall"], response["task_response"]) response['fixed_text'] = get_fixed_text(answer) + ai_detection = gpt_zero.run_detection(answer) + if ai_detection is not None: + response['ai_detection'] = ai_detection return response except Exception as e: return str(e) @@ -449,6 +455,9 @@ def grade_writing_task_2(): response["perfect_answer"] = get_perfect_answer(question, 250)["perfect_answer"] response["overall"] = fix_writing_overall(response["overall"], response["task_response"]) response['fixed_text'] = get_fixed_text(answer) + ai_detection = gpt_zero.run_detection(answer) + if ai_detection is not None: + response['ai_detection'] = ai_detection return response except Exception as e: return str(e) diff --git a/helper/gpt_zero.py b/helper/gpt_zero.py new file mode 100644 index 0000000..8ab79f0 --- /dev/null +++ b/helper/gpt_zero.py @@ -0,0 +1,49 @@ +from logging import getLogger +from typing import Dict, Optional +import requests + + +class GPTZero: + _GPT_ZERO_ENDPOINT = 'https://api.gptzero.me/v2/predict/text' + + def __init__(self, gpt_zero_key: str): + self._logger = getLogger(__name__) + if gpt_zero_key is None: + self._logger.warning('GPT Zero key was not included! Skipping ai detection when grading.') + self._gpt_zero_key = gpt_zero_key + self._header = { + 'x-api-key': gpt_zero_key + } + + def run_detection(self, text: str): + if self._gpt_zero_key is None: + return None + data = { + 'document': text, + 'version': '', + 'multilingual': False + } + response = requests.post(self._GPT_ZERO_ENDPOINT, headers=self._header, json=data) + if response.status_code != 200: + return None + return self._parse_detection(response.json()) + + def _parse_detection(self, response: Dict) -> Optional[Dict]: + try: + text_scan = response["documents"][0] + filtered_sentences = [ + { + "sentence": item["sentence"], + "highlight_sentence_for_ai": item["highlight_sentence_for_ai"] + } + for item in text_scan["sentences"] + ] + return { + "class_probabilities": text_scan["class_probabilities"], + "confidence_category": text_scan["confidence_category"], + "predicted_class": text_scan["predicted_class"], + "sentences": filtered_sentences + } + except Exception as e: + self._logger.error(f'Failed to parse GPT\'s Zero response: {str(e)}') + return None diff --git a/helper/heygen_api.py b/helper/heygen_api.py index d0e2d8c..864794b 100644 --- a/helper/heygen_api.py +++ b/helper/heygen_api.py @@ -1,17 +1,19 @@ import os import random import time +from logging import getLogger import requests from dotenv import load_dotenv -import app from helper.constants import * from helper.firebase_helper import upload_file_firebase_get_url, save_to_db_with_id from heygen.AvatarEnum import AvatarEnum load_dotenv() +logger = getLogger(__name__) + # Get HeyGen token TOKEN = os.getenv("HEY_GEN_TOKEN") FIREBASE_BUCKET = os.getenv('FIREBASE_BUCKET') @@ -37,7 +39,7 @@ def create_videos_and_save_to_db(exercises, template, id): if found_exercises_1: exercise_1 = found_exercises_1[0] sp1_questions = [] - app.app.logger.info('Creating video for speaking part 1') + logger.info('Creating video for speaking part 1') for question in exercise_1["questions"]: sp1_result = create_video(question, avatar) if sp1_result is not None: @@ -51,7 +53,7 @@ def create_videos_and_save_to_db(exercises, template, id): } sp1_questions.append(video) else: - app.app.logger.error("Failed to create video for part 1 question: " + exercise_1["question"]) + logger.error("Failed to create video for part 1 question: " + exercise_1["question"]) template["exercises"][0]["prompts"] = sp1_questions template["exercises"][0]["first_title"] = exercise_1["first_topic"] template["exercises"][0]["second_title"] = exercise_1["second_topic"] @@ -62,7 +64,7 @@ def create_videos_and_save_to_db(exercises, template, id): # Check if any elements were found if found_exercises_2: exercise_2 = found_exercises_2[0] - app.app.logger.info('Creating video for speaking part 2') + logger.info('Creating video for speaking part 2') sp2_result = create_video(exercise_2["question"], avatar) if sp2_result is not None: sound_file_path = VIDEO_FILES_PATH + sp2_result @@ -76,7 +78,7 @@ def create_videos_and_save_to_db(exercises, template, id): template["exercises"][1]["video_url"] = sp2_video_url template["exercises"][1]["video_path"] = sp2_video_path else: - app.app.logger.error("Failed to create video for part 2 question: " + exercise_2["question"]) + logger.error("Failed to create video for part 2 question: " + exercise_2["question"]) # Speaking 3 # Using list comprehension to find the element with the desired value in the 'type' field @@ -85,7 +87,7 @@ def create_videos_and_save_to_db(exercises, template, id): if found_exercises_3: exercise_3 = found_exercises_3[0] sp3_questions = [] - app.app.logger.info('Creating videos for speaking part 3') + logger.info('Creating videos for speaking part 3') for question in exercise_3["questions"]: result = create_video(question, avatar) if result is not None: @@ -99,7 +101,7 @@ def create_videos_and_save_to_db(exercises, template, id): } sp3_questions.append(video) else: - app.app.logger.error("Failed to create video for part 3 question: " + question) + logger.error("Failed to create video for part 3 question: " + question) template["exercises"][2]["prompts"] = sp3_questions template["exercises"][2]["title"] = exercise_3["topic"] @@ -111,7 +113,7 @@ def create_videos_and_save_to_db(exercises, template, id): template["exercises"].pop(0) save_to_db_with_id("speaking", template, id) - app.app.logger.info('Saved speaking to DB with id ' + id + " : " + str(template)) + logger.info('Saved speaking to DB with id ' + id + " : " + str(template)) def create_video(text, avatar): @@ -132,8 +134,8 @@ def create_video(text, avatar): } } response = requests.post(create_video_url, headers=POST_HEADER, json=data) - app.app.logger.info(response.status_code) - app.app.logger.info(response.json()) + logger.info(response.status_code) + logger.info(response.json()) # GET TO CHECK STATUS AND GET VIDEO WHEN READY video_id = response.json()["data"]["video_id"] @@ -152,11 +154,11 @@ def create_video(text, avatar): error = response_data["data"]["error"] if status != "completed" and error is None: - app.app.logger.info(f"Status: {status}") + logger.info(f"Status: {status}") time.sleep(10) # Wait for 10 second before the next request - app.app.logger.info(response.status_code) - app.app.logger.info(response.json()) + logger.info(response.status_code) + logger.info(response.json()) # DOWNLOAD VIDEO download_url = response.json()['data']['video_url'] @@ -170,8 +172,8 @@ def create_video(text, avatar): output_path = os.path.join(output_directory, output_filename) with open(output_path, 'wb') as f: f.write(response.content) - app.app.logger.info(f"File '{output_filename}' downloaded successfully.") + logger.info(f"File '{output_filename}' downloaded successfully.") return output_filename else: - app.app.logger.error(f"Failed to download file. Status code: {response.status_code}") + logger.error(f"Failed to download file. Status code: {response.status_code}") return None From eb904f836afda920bdeef2358f5ecd72e1174aae Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Thu, 25 Jul 2024 17:01:09 +0100 Subject: [PATCH 24/44] Forgot to change the .env --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index efadb83..979e608 100644 --- a/.env +++ b/.env @@ -1,6 +1,6 @@ OPENAI_API_KEY=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 JWT_SECRET_KEY=6e9c124ba92e8814719dcb0f21200c8aa4d0f119a994ac5e06eb90a366c83ab2 JWT_TEST_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.Emrs2D3BmMP4b3zMjw0fJTPeyMwWEBDbxx2vvaWguO0 -GOOGLE_APPLICATION_CREDENTIALS=firebase-configs/test_firebase.json +GOOGLE_APPLICATION_CREDENTIALS=firebase-configs/storied-phalanx-349916.json HEY_GEN_TOKEN=MjY4MDE0MjdjZmNhNDFmYTlhZGRkNmI3MGFlMzYwZDItMTY5NTExNzY3MA== GPT_ZERO_API_KEY=0195b9bb24c5439899f71230809c74af From 34afb5d1e8de7341a8b3f282e9e4ce4a52f13fa4 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Thu, 25 Jul 2024 17:11:14 +0100 Subject: [PATCH 25/44] Logging when GPT's Zero response != 200 --- helper/gpt_zero.py | 1 + 1 file changed, 1 insertion(+) diff --git a/helper/gpt_zero.py b/helper/gpt_zero.py index 8ab79f0..08c4f1a 100644 --- a/helper/gpt_zero.py +++ b/helper/gpt_zero.py @@ -25,6 +25,7 @@ class GPTZero: } response = requests.post(self._GPT_ZERO_ENDPOINT, headers=self._header, json=data) if response.status_code != 200: + self._logger.error(f'GPT\'s Zero Endpoint returned with {response.status_code}: {response.json()}') return None return self._parse_detection(response.json()) From 19f204d74d5d254c72c4043e8af89d11bcbbc08b Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 26 Jul 2024 15:59:11 +0100 Subject: [PATCH 26/44] Add default for topic on custom level and random reorder for multiple choice options. --- app.py | 3 ++- helper/exercises.py | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/app.py b/app.py index 07d324a..13e638e 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,4 @@ +import random import threading from functools import reduce @@ -1512,7 +1513,7 @@ def get_custom_level(): for i in range(1, nr_exercises + 1, 1): exercise_type = request.args.get('exercise_' + str(i) + '_type') exercise_qty = int(request.args.get('exercise_' + str(i) + '_qty', -1)) - exercise_topic = request.args.get('exercise_' + str(i) + '_topic') + exercise_topic = request.args.get('exercise_' + str(i) + '_topic', random.choice(topics)) exercise_text_size = int(request.args.get('exercise_' + str(i) + '_text_size', -1)) exercise_sa_qty = int(request.args.get('exercise_' + str(i) + '_sa_qty', -1)) exercise_mc_qty = int(request.args.get('exercise_' + str(i) + '_mc_qty', -1)) diff --git a/helper/exercises.py b/helper/exercises.py index 428ebc3..bec90b5 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -1368,7 +1368,9 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_exams, question["questions"][i], question, seen_keys) - return fix_exercise_ids(question, start_id) + response = fix_exercise_ids(question, start_id) + response["questions"] = randomize_mc_options_order(response["questions"]) + return response def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): @@ -1437,7 +1439,9 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): if len(question["questions"]) != quantity: return gen_multiple_choice_level(quantity, start_id) else: - return fix_exercise_ids(question, start_id) + response = fix_exercise_ids(question, start_id) + response["questions"] = randomize_mc_options_order(response["questions"]) + return response def gen_blank_space_text_utas(quantity: int, start_id: int, size: int, topic=random.choice(mti_topics)): @@ -1590,7 +1594,9 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): if len(question["questions"]) != mc_quantity: return gen_multiple_choice_level(mc_quantity, start_id) else: - return fix_exercise_ids(question, start_id)["questions"] + response = fix_exercise_ids(question, start_id) + response["questions"] = randomize_mc_options_order(response["questions"]) + return response def generate_level_mc(start_id: int, quantity: int): @@ -1644,4 +1650,26 @@ def generate_level_mc(start_id: int, quantity: int): question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE) - return fix_exercise_ids(question, start_id) + response = fix_exercise_ids(question, start_id) + response["questions"] = randomize_mc_options_order(response["questions"]) + return response + + +def randomize_mc_options_order(questions): + option_ids = ['A', 'B', 'C', 'D'] + + for question in questions: + # Store the original solution text + original_solution_text = next( + option['text'] for option in question['options'] if option['id'] == question['solution']) + + # Shuffle the options + random.shuffle(question['options']) + + # Update the option ids and find the new solution id + for idx, option in enumerate(question['options']): + option['id'] = option_ids[idx] + if option['text'] == original_solution_text: + question['solution'] = option['id'] + + return questions From 3a7bb7764ff7a62755ab22d1546fb004ec4c309b Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 26 Jul 2024 23:33:42 +0100 Subject: [PATCH 27/44] Writing improvements. --- app.py | 222 +++++++++++++++++++++++++++++++-------------------------- 1 file changed, 121 insertions(+), 101 deletions(-) diff --git a/app.py b/app.py index 13e638e..a0b26be 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,3 @@ -import random import threading from functools import reduce @@ -12,11 +11,11 @@ from helper.exam_variant import ExamVariant from helper.exercises import * from helper.file_helper import delete_files_older_than_one_day from helper.firebase_helper import * +from helper.gpt_zero import GPTZero from helper.heygen_api import create_video, create_videos_and_save_to_db from helper.openai_interface import * from helper.question_templates import * from helper.speech_to_text_helper import * -from helper.gpt_zero import GPTZero from heygen.AvatarEnum import AvatarEnum load_dotenv() @@ -226,22 +225,22 @@ def grade_writing_task_1(): 'comment': "The answer does not contain enough english words.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': { - "grade": 0.0, - "comment": "" - }, - 'Grammatical Range and Accuracy': { - "grade": 0.0, - "comment": "" - }, - 'Lexical Resource': { - "grade": 0.0, - "comment": "" - }, 'Task Achievement': { - "grade": 0.0, - "comment": "" - } + "grade": 0.0, + "comment": "" + }, + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + } } } elif not has_x_words(answer, 100): @@ -249,22 +248,22 @@ def grade_writing_task_1(): 'comment': "The answer is insufficient and too small to be graded.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': { - "grade": 0.0, - "comment": "" - }, - 'Grammatical Range and Accuracy': { - "grade": 0.0, - "comment": "" - }, - 'Lexical Resource': { - "grade": 0.0, - "comment": "" - }, 'Task Achievement': { - "grade": 0.0, - "comment": "" - } + "grade": 0.0, + "comment": "" + }, + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + } } } else: @@ -272,21 +271,21 @@ def grade_writing_task_1(): "comment": "comment about student's response quality", "overall": 0.0, "task_response": { + "Task Achievement": { + "grade": 0.0, + "comment": "comment about Task Achievement of the student's response" + }, "Coherence and Cohesion": { "grade": 0.0, "comment": "comment about Coherence and Cohesion of the student's response" }, - "Grammatical Range and Accuracy": { - "grade": 0.0, - "comment": "comment about Grammatical Range and Accuracy of the student's response" - }, "Lexical Resource": { "grade": 0.0, "comment": "comment about Lexical Resource of the student's response" }, - "Task Achievement": { + "Grammatical Range and Accuracy": { "grade": 0.0, - "comment": "comment about Task Achievement of the student's response" + "comment": "comment about Grammatical Range and Accuracy of the student's response" } } } @@ -294,7 +293,8 @@ def grade_writing_task_1(): messages = [ { "role": "system", - "content": ('You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + "content": ('You are a helpful assistant designed to output JSON on this format: ' + str( + json_format)) }, { "role": "user", @@ -343,19 +343,26 @@ def get_writing_task_1_general_question(): 'of ' + difficulty + 'difficulty and does not contain ' 'forbidden subjects in muslim ' 'countries.') + }, + { + "role": "user", + "content": 'The prompt should end with "In the letter you should" followed by 3 bullet points of what ' + 'the answer should include.' } ] token_count = count_total_tokens(messages) response = make_openai_call(GPT_3_5_TURBO, messages, token_count, "prompt", GEN_QUESTION_TEMPERATURE) return { - "question": response["prompt"].strip(), + "question": add_newline_before_hyphen(response["prompt"].strip()), "difficulty": difficulty, "topic": topic } except Exception as e: return str(e) +def add_newline_before_hyphen(s): + return s.replace(" -", "\n-") @app.route('/writing_task2', methods=['POST']) @jwt_required() @@ -369,22 +376,22 @@ def grade_writing_task_2(): 'comment': "The answer does not contain enough english words.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': { - "grade": 0.0, - "comment": "" - }, - 'Grammatical Range and Accuracy': { - "grade": 0.0, - "comment": "" - }, - 'Lexical Resource': { - "grade": 0.0, - "comment": "" - }, 'Task Achievement': { - "grade": 0.0, - "comment": "" - } + "grade": 0.0, + "comment": "" + }, + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + } } } elif not has_x_words(answer, 180): @@ -392,22 +399,22 @@ def grade_writing_task_2(): 'comment': "The answer is insufficient and too small to be graded.", 'overall': 0, 'task_response': { - 'Coherence and Cohesion': { - "grade": 0.0, - "comment": "" - }, - 'Grammatical Range and Accuracy': { - "grade": 0.0, - "comment": "" - }, - 'Lexical Resource': { - "grade": 0.0, - "comment": "" - }, 'Task Achievement': { - "grade": 0.0, - "comment": "" - } + "grade": 0.0, + "comment": "" + }, + 'Coherence and Cohesion': { + "grade": 0.0, + "comment": "" + }, + 'Lexical Resource': { + "grade": 0.0, + "comment": "" + }, + 'Grammatical Range and Accuracy': { + "grade": 0.0, + "comment": "" + } } } else: @@ -415,21 +422,21 @@ def grade_writing_task_2(): "comment": "comment about student's response quality", "overall": 0.0, "task_response": { + "Task Achievement": { + "grade": 0.0, + "comment": "comment about Task Achievement of the student's response" + }, "Coherence and Cohesion": { "grade": 0.0, "comment": "comment about Coherence and Cohesion of the student's response" }, - "Grammatical Range and Accuracy": { - "grade": 0.0, - "comment": "comment about Grammatical Range and Accuracy of the student's response" - }, "Lexical Resource": { "grade": 0.0, "comment": "comment about Lexical Resource of the student's response" }, - "Task Achievement": { + "Grammatical Range and Accuracy": { "grade": 0.0, - "comment": "comment about Task Achievement of the student's response" + "comment": "comment about Grammatical Range and Accuracy of the student's response" } } } @@ -437,7 +444,8 @@ def grade_writing_task_2(): messages = [ { "role": "system", - "content": ('You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + "content": ('You are a helpful assistant designed to output JSON on this format: ' + str( + json_format)) }, { "role": "user", @@ -493,8 +501,12 @@ def get_writing_task_2_general_question(): "content": ( 'Craft a comprehensive question of ' + difficulty + 'difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate ' 'to delve into an in-depth analysis of contrasting perspectives on the topic of "' + topic + '". ' - 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints, provide evidence or ' - 'examples, and present a well-rounded argument before concluding with their personal opinion on the subject.') + 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints.') + }, + { + "role": "user", + "content": 'The question should lead to an answer with either "theories", "complicated information" or ' + 'be "very descriptive" on the topic.' } ] token_count = count_total_tokens(messages) @@ -708,16 +720,16 @@ def get_speaking_task_1_question(): "role": "user", "content": ( 'Craft 5 simple questions of easy difficulty for IELTS Speaking Part 1 ' - 'that encourages candidates to delve deeply into ' - 'personal experiences, preferences, or insights on the topic ' - 'of "' + first_topic + '" and the topic of "' + second_topic + '". Instruct the candidate ' - 'to offer not only detailed ' - 'descriptions but also provide ' - 'nuanced explanations, examples, ' - 'or anecdotes to enrich their response. ' - 'Make sure that the generated question ' - 'does not contain forbidden subjects in ' - 'muslim countries.') + 'that encourages candidates to delve deeply into ' + 'personal experiences, preferences, or insights on the topic ' + 'of "' + first_topic + '" and the topic of "' + second_topic + '". Instruct the candidate ' + 'to offer not only detailed ' + 'descriptions but also provide ' + 'nuanced explanations, examples, ' + 'or anecdotes to enrich their response. ' + 'Make sure that the generated question ' + 'does not contain forbidden subjects in ' + 'muslim countries.') }, { "role": "user", @@ -900,13 +912,13 @@ def get_speaking_task_2_question(): 'that encourages candidates to narrate a ' 'personal experience or story related to the topic ' 'of "' + topic + '". Include 3 prompts that ' - 'guide the candidate to describe ' - 'specific aspects of the experience, ' - 'such as details about the situation, ' - 'their actions, and the reasons it left a ' - 'lasting impression. Make sure that the ' - 'generated question does not contain ' - 'forbidden subjects in muslim countries.') + 'guide the candidate to describe ' + 'specific aspects of the experience, ' + 'such as details about the situation, ' + 'their actions, and the reasons it left a ' + 'lasting impression. Make sure that the ' + 'generated question does not contain ' + 'forbidden subjects in muslim countries.') }, { "role": "user", @@ -952,8 +964,8 @@ def get_speaking_task_3_question(): "content": ( 'Formulate a set of 5 questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' 'meaningful discussion on the topic of "' + topic + '". Provide inquiries, ensuring ' - 'they explore various aspects, perspectives, and implications related to the topic.' - 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') + 'they explore various aspects, perspectives, and implications related to the topic.' + 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') } ] @@ -1141,6 +1153,7 @@ def fix_speaking_overall(overall: float, task_response: dict): return overall + @app.route('/speaking', methods=['POST']) @jwt_required() def save_speaking(): @@ -1491,8 +1504,10 @@ def get_level_utas(): except Exception as e: return str(e) + from enum import Enum + class CustomLevelExerciseTypes(Enum): MULTIPLE_CHOICE_4 = "multiple_choice_4" MULTIPLE_CHOICE_BLANK_SPACE = "multiple_choice_blank_space" @@ -1500,6 +1515,7 @@ class CustomLevelExerciseTypes(Enum): BLANK_SPACE_TEXT = "blank_space_text" READING_PASSAGE_UTAS = "reading_passage_utas" + @app.route('/custom_level', methods=['GET']) @jwt_required() def get_custom_level(): @@ -1523,7 +1539,8 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" exercise_id = exercise_id + exercise_qty elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_BLANK_SPACE.value: - response["exercises"]["exercise_" + str(i)] = gen_multiple_choice_blank_space_utas(exercise_qty, exercise_id) + response["exercises"]["exercise_" + str(i)] = gen_multiple_choice_blank_space_utas(exercise_qty, + exercise_id) response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" exercise_id = exercise_id + exercise_qty elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_UNDERLINED.value: @@ -1531,16 +1548,19 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" exercise_id = exercise_id + exercise_qty elif exercise_type == CustomLevelExerciseTypes.BLANK_SPACE_TEXT.value: - response["exercises"]["exercise_" + str(i)] = gen_blank_space_text_utas(exercise_qty, exercise_id, exercise_text_size) + response["exercises"]["exercise_" + str(i)] = gen_blank_space_text_utas(exercise_qty, exercise_id, + exercise_text_size) response["exercises"]["exercise_" + str(i)]["type"] = "blankSpaceText" exercise_id = exercise_id + exercise_qty elif exercise_type == CustomLevelExerciseTypes.READING_PASSAGE_UTAS.value: - response["exercises"]["exercise_" + str(i)] = gen_reading_passage_utas(exercise_id, exercise_sa_qty, exercise_mc_qty, exercise_topic) + response["exercises"]["exercise_" + str(i)] = gen_reading_passage_utas(exercise_id, exercise_sa_qty, + exercise_mc_qty, exercise_topic) response["exercises"]["exercise_" + str(i)]["type"] = "readingExercises" exercise_id = exercise_id + exercise_qty return response + @app.route('/fetch_tips', methods=['POST']) @jwt_required() def fetch_answer_tips(): From adfc027458f35be04b8af825403b0378231019ad Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Fri, 26 Jul 2024 23:46:46 +0100 Subject: [PATCH 28/44] Add excerpts to reading 3. --- helper/exercises.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/helper/exercises.py b/helper/exercises.py index bec90b5..8eddcb3 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -359,6 +359,10 @@ def generate_reading_passage_3_text(topic: str): 'subtle differences of opinions from people, correctly sourced to the person who said it, ' 'over the specified topic and have multiple paragraphs.') }, + { + "role": "user", + "content": "Use real text excerpts on you generated passage and cite the sources." + } ] token_count = count_total_tokens(messages) return make_openai_call(GPT_4_O, messages, token_count, GEN_TEXT_FIELDS, GEN_QUESTION_TEMPERATURE) From a1ee7e47dacfe96d58cab3151736d4dd532b4601 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Sun, 28 Jul 2024 14:33:08 +0100 Subject: [PATCH 29/44] Can now generate lots of mc in level custom. --- app.py | 53 ++++++++++++++++--- helper/exercises.py | 124 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 164 insertions(+), 13 deletions(-) diff --git a/app.py b/app.py index a0b26be..1d07c37 100644 --- a/app.py +++ b/app.py @@ -361,9 +361,11 @@ def get_writing_task_1_general_question(): except Exception as e: return str(e) + def add_newline_before_hyphen(s): return s.replace(" -", "\n-") + @app.route('/writing_task2', methods=['POST']) @jwt_required() def grade_writing_task_2(): @@ -501,7 +503,7 @@ def get_writing_task_2_general_question(): "content": ( 'Craft a comprehensive question of ' + difficulty + 'difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate ' 'to delve into an in-depth analysis of contrasting perspectives on the topic of "' + topic + '". ' - 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints.') + 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints.') }, { "role": "user", @@ -1535,18 +1537,53 @@ def get_custom_level(): exercise_mc_qty = int(request.args.get('exercise_' + str(i) + '_mc_qty', -1)) if exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_4.value: - response["exercises"]["exercise_" + str(i)] = generate_level_mc(exercise_id, exercise_qty) + response["exercises"]["exercise_" + str(i)] = {} + response["exercises"]["exercise_" + str(i)]["questions"] = [] response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" - exercise_id = exercise_id + exercise_qty + while exercise_qty > 0: + if exercise_qty - 15 > 0: + qty = 15 + else: + qty = exercise_qty + + response["exercises"]["exercise_" + str(i)]["questions"].extend( + generate_level_mc(exercise_id, qty, + response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) + exercise_id = exercise_id + qty + exercise_qty = exercise_qty - qty + elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_BLANK_SPACE.value: - response["exercises"]["exercise_" + str(i)] = gen_multiple_choice_blank_space_utas(exercise_qty, - exercise_id) + response["exercises"]["exercise_" + str(i)] = {} + response["exercises"]["exercise_" + str(i)]["questions"] = [] response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" - exercise_id = exercise_id + exercise_qty + while exercise_qty > 0: + if exercise_qty - 15 > 0: + qty = 15 + else: + qty = exercise_qty + + response["exercises"]["exercise_" + str(i)]["questions"].extend( + gen_multiple_choice_blank_space_utas(qty, exercise_id, + response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) + exercise_id = exercise_id + exercise_qty + exercise_qty = exercise_qty - qty + elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_UNDERLINED.value: - response["exercises"]["exercise_" + str(i)] = gen_multiple_choice_underlined_utas(exercise_qty, exercise_id) + response["exercises"]["exercise_" + str(i)] = {} + response["exercises"]["exercise_" + str(i)]["questions"] = [] response["exercises"]["exercise_" + str(i)]["type"] = "multipleChoice" - exercise_id = exercise_id + exercise_qty + while exercise_qty > 0: + if exercise_qty - 15 > 0: + qty = 15 + else: + qty = exercise_qty + + response["exercises"]["exercise_" + str(i)]["questions"].extend( + gen_multiple_choice_underlined_utas(qty, exercise_id, + response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) + exercise_id = exercise_id + exercise_qty + exercise_qty = exercise_qty - qty + elif exercise_type == CustomLevelExerciseTypes.BLANK_SPACE_TEXT.value: response["exercises"]["exercise_" + str(i)] = gen_blank_space_text_utas(exercise_qty, exercise_id, exercise_text_size) diff --git a/helper/exercises.py b/helper/exercises.py index 8eddcb3..53321c4 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -1297,6 +1297,48 @@ def replace_exercise_if_exists_utas(all_exams, current_exercise, current_exam, s return current_exercise, seen_keys +def replace_blank_space_exercise_if_exists_utas(all_exams, current_exercise, current_exam, seen_keys): + # Extracting relevant fields for comparison + key = (current_exercise['prompt'], tuple(sorted(option['text'] for option in current_exercise['options']))) + # Check if the key is in the set + if key in seen_keys: + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_blank_space_level_question(), current_exam, seen_keys) + else: + seen_keys.add(key) + + for exam in all_exams: + if any( + exercise["prompt"] == current_exercise["prompt"] and + any(exercise["options"][0]["text"] == current_option["text"] for current_option in + current_exercise["options"]) + for exercise in exam.get("questions", []) + ): + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_blank_space_level_question(), current_exam, + seen_keys) + return current_exercise, seen_keys + + +def replace_underlined_exercise_if_exists_utas(all_exams, current_exercise, current_exam, seen_keys): + # Extracting relevant fields for comparison + key = (current_exercise['prompt'], tuple(sorted(option['text'] for option in current_exercise['options']))) + # Check if the key is in the set + if key in seen_keys: + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_underlined_level_question(), current_exam, seen_keys) + else: + seen_keys.add(key) + + for exam in all_exams: + if any( + exercise["prompt"] == current_exercise["prompt"] and + any(exercise["options"][0]["text"] == current_option["text"] for current_option in + current_exercise["options"]) + for exercise in exam.get("questions", []) + ): + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_underlined_level_question(), current_exam, + seen_keys) + return current_exercise, seen_keys + + def generate_single_mc_level_question(): messages = [ { @@ -1322,6 +1364,64 @@ def generate_single_mc_level_question(): return question +def generate_single_mc_blank_space_level_question(): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"id": "9", "options": [{"id": "A", "text": "And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' + '"Happy"}, {"id": "D", "text": "Jump"}], "prompt": "Which of the following is a conjunction?", ' + '"solution": "A", "variant": "text"}') + }, + { + "role": "user", + "content": ('Generate 1 multiple choice blank space question of 4 options for an english level exam, it can be easy, ' + 'intermediate or advanced.') + + } + ] + token_count = count_total_tokens(messages) + + question = make_openai_call(GPT_4_O, messages, token_count, ["options"], + GEN_QUESTION_TEMPERATURE) + + return question + + +def generate_single_mc_underlined_level_question(): + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + '{"id": "9", "options": [{"id": "A", "text": "And"}, {"id": "B", "text": "Cat"}, {"id": "C", "text": ' + '"Happy"}, {"id": "D", "text": "Jump"}], "prompt": "Which of the following is a conjunction?", ' + '"solution": "A", "variant": "text"}') + }, + { + "role": "user", + "content": ('Generate 1 multiple choice blank space question of 4 options for an english level exam, it can be easy, ' + 'intermediate or advanced.') + + }, + { + "role": "user", + "content": ( + 'The type of multiple choice is the prompt has wrong words or group of words and the options are to ' + 'find the wrong word or group of words that are underlined in the prompt. \nExample:\n' + 'Prompt: "I complain about my boss all the time, but my colleagues thinks the boss is nice."\n' + 'Options:\na: "complain"\nb: "all the time"\nc: "thinks"\nd: "is"') + } + ] + token_count = count_total_tokens(messages) + + question = make_openai_call(GPT_4_O, messages, token_count, ["options"], + GEN_QUESTION_TEMPERATURE) + + return question + + def parse_conversation(conversation_data): conversation_list = conversation_data.get('conversation', []) readable_text = [] @@ -1364,12 +1464,12 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: - return gen_multiple_choice_level(quantity, start_id) + return gen_multiple_choice_blank_space_utas(quantity, start_id) else: if all_exams is not None: seen_keys = set() for i in range(len(question["questions"])): - question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_exams, question["questions"][i], + question["questions"][i], seen_keys = replace_blank_space_exercise_if_exists_utas(all_exams, question["questions"][i], question, seen_keys) response = fix_exercise_ids(question, start_id) @@ -1377,7 +1477,7 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams return response -def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): +def gen_multiple_choice_underlined_utas(quantity: int, start_id: int, all_exams=None): json_format = { "questions": [ { @@ -1441,8 +1541,16 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int): GEN_QUESTION_TEMPERATURE) if len(question["questions"]) != quantity: - return gen_multiple_choice_level(quantity, start_id) + return gen_multiple_choice_underlined_utas(quantity, start_id) else: + if all_exams is not None: + seen_keys = set() + for i in range(len(question["questions"])): + question["questions"][i], seen_keys = replace_underlined_exercise_if_exists_utas(all_exams, + question["questions"][ + i], + question, + seen_keys) response = fix_exercise_ids(question, start_id) response["questions"] = randomize_mc_options_order(response["questions"]) return response @@ -1603,7 +1711,7 @@ def gen_text_multiple_choice_utas(text: str, start_id: int, mc_quantity: int): return response -def generate_level_mc(start_id: int, quantity: int): +def generate_level_mc(start_id: int, quantity: int, all_questions=None): json_format = { "questions": [ { @@ -1654,6 +1762,12 @@ def generate_level_mc(start_id: int, quantity: int): question = make_openai_call(GPT_4_O, messages, token_count, ["questions"], GEN_QUESTION_TEMPERATURE) + if all_questions is not None: + seen_keys = set() + for i in range(len(question["questions"])): + question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_questions, question["questions"][i], + question, + seen_keys) response = fix_exercise_ids(question, start_id) response["questions"] = randomize_mc_options_order(response["questions"]) return response From 1f29ac6ee5b2e9c2aa7b19d397cab57753fd0622 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 30 Jul 2024 19:53:17 +0100 Subject: [PATCH 30/44] Fix id on custom level. --- app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index 1d07c37..3b66c51 100644 --- a/app.py +++ b/app.py @@ -1565,7 +1565,7 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["questions"].extend( gen_multiple_choice_blank_space_utas(qty, exercise_id, response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) - exercise_id = exercise_id + exercise_qty + exercise_id = exercise_id + qty exercise_qty = exercise_qty - qty elif exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_UNDERLINED.value: @@ -1581,7 +1581,7 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["questions"].extend( gen_multiple_choice_underlined_utas(qty, exercise_id, response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) - exercise_id = exercise_id + exercise_qty + exercise_id = exercise_id + qty exercise_qty = exercise_qty - qty elif exercise_type == CustomLevelExerciseTypes.BLANK_SPACE_TEXT.value: From 6878e0a276ce7a8e942e79c329162f1950fef0d0 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 30 Jul 2024 22:34:31 +0100 Subject: [PATCH 31/44] Added the ability to send the ID for the listening --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 3b66c51..92b1219 100644 --- a/app.py +++ b/app.py @@ -180,7 +180,7 @@ def save_listening(): difficulty = data.get('difficulty', random.choice(difficulties)) template = getListeningTemplate() template['difficulty'] = difficulty - id = str(uuid.uuid4()) + id = data.get('id', str(uuid.uuid4())) for i, part in enumerate(parts, start=0): part_template = getListeningPartTemplate() From 14c5914420ce38e42cb2b93dc25b1e5ef9fa68ca Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 30 Jul 2024 22:40:13 +0100 Subject: [PATCH 32/44] Add default text size blank space custom level. --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 92b1219..a8acf76 100644 --- a/app.py +++ b/app.py @@ -1532,7 +1532,7 @@ def get_custom_level(): exercise_type = request.args.get('exercise_' + str(i) + '_type') exercise_qty = int(request.args.get('exercise_' + str(i) + '_qty', -1)) exercise_topic = request.args.get('exercise_' + str(i) + '_topic', random.choice(topics)) - exercise_text_size = int(request.args.get('exercise_' + str(i) + '_text_size', -1)) + exercise_text_size = int(request.args.get('exercise_' + str(i) + '_text_size', 700)) exercise_sa_qty = int(request.args.get('exercise_' + str(i) + '_sa_qty', -1)) exercise_mc_qty = int(request.args.get('exercise_' + str(i) + '_mc_qty', -1)) From 8e56a3228b658607758aa60db0ab054e5625214d Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Wed, 31 Jul 2024 14:56:33 +0100 Subject: [PATCH 33/44] Finished training content backend --- app.py | 21 ++ faiss/ct_focus_tips_index.faiss | Bin 0 -> 29229 bytes faiss/language_for_writing_tips_index.faiss | Bin 0 -> 13869 bytes faiss/reading_skill_tips_index.faiss | Bin 0 -> 13869 bytes faiss/strategy_tips_index.faiss | Bin 0 -> 29229 bytes faiss/tips_metadata.pkl | Bin 0 -> 34082 bytes faiss/word_link_tips_index.faiss | Bin 0 -> 15405 bytes faiss/word_partners_tips_index.faiss | Bin 0 -> 20013 bytes faiss/writing_skill_tips_index.faiss | Bin 0 -> 12333 bytes requirements.txt | Bin 670 -> 782 bytes training_content/__init__.py | 9 + training_content/dtos.py | 29 ++ training_content/gpt.py | 64 +++++ training_content/kb.py | 85 ++++++ training_content/service.py | 278 ++++++++++++++++++++ 15 files changed, 486 insertions(+) create mode 100644 faiss/ct_focus_tips_index.faiss create mode 100644 faiss/language_for_writing_tips_index.faiss create mode 100644 faiss/reading_skill_tips_index.faiss create mode 100644 faiss/strategy_tips_index.faiss create mode 100644 faiss/tips_metadata.pkl create mode 100644 faiss/word_link_tips_index.faiss create mode 100644 faiss/word_partners_tips_index.faiss create mode 100644 faiss/writing_skill_tips_index.faiss create mode 100644 training_content/__init__.py create mode 100644 training_content/dtos.py create mode 100644 training_content/gpt.py create mode 100644 training_content/kb.py create mode 100644 training_content/service.py diff --git a/app.py b/app.py index a0b26be..555a7b7 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ import firebase_admin from firebase_admin import credentials from flask import Flask, request from flask_jwt_extended import JWTManager, jwt_required +from sentence_transformers import SentenceTransformer from helper.api_messages import * from helper.exam_variant import ExamVariant @@ -17,6 +18,7 @@ from helper.openai_interface import * from helper.question_templates import * from helper.speech_to_text_helper import * from heygen.AvatarEnum import AvatarEnum +from training_content import TrainingContentService, TrainingContentKnowledgeBase, GPT load_dotenv() @@ -33,6 +35,14 @@ firebase_admin.initialize_app(cred) gpt_zero = GPTZero(os.getenv('GPT_ZERO_API_KEY')) +# Training Content Dependencies +embeddings = SentenceTransformer('all-MiniLM-L6-v2') +kb = TrainingContentKnowledgeBase(embeddings) +kb.load_indices_and_metadata() +open_ai = GPT(OpenAI()) +firestore_client = firestore.client() +tc_service = TrainingContentService(kb, open_ai, firestore_client) + thread_event = threading.Event() # Configure logging @@ -1596,5 +1606,16 @@ def grading_summary(): return str(e) +@app.route('/training_content', methods=['POST']) +@jwt_required() +def training_content(): + try: + data = request.get_json() + return tc_service.get_tips(data) + except Exception as e: + app.logger.error(str(e)) + return str(e) + + if __name__ == '__main__': app.run() diff --git a/faiss/ct_focus_tips_index.faiss b/faiss/ct_focus_tips_index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..909571be40710e89d8acd843f35f8bf190f65b2e GIT binary patch literal 29229 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 literal 0 HcmV?d00001 diff --git a/faiss/language_for_writing_tips_index.faiss b/faiss/language_for_writing_tips_index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..b9b254ca1aaee6714c6487f09b6a89af0e86ab35 GIT binary patch literal 13869 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 literal 0 HcmV?d00001 diff --git a/faiss/strategy_tips_index.faiss b/faiss/strategy_tips_index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..8032155f17ed89af08cb9fb5264cd75fa189913e GIT binary patch literal 29229 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

s{_yXj*xi`IUqOj6&t@?rPIHz#ipT9 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 literal 0 HcmV?d00001 diff --git a/faiss/tips_metadata.pkl b/faiss/tips_metadata.pkl new file mode 100644 index 0000000000000000000000000000000000000000..ecb36142da6ad222e40a66b67a1071bb0b6a60d6 GIT binary patch literal 34082 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 Hx%2-61JZcJ literal 0 HcmV?d00001 diff --git a/faiss/word_partners_tips_index.faiss b/faiss/word_partners_tips_index.faiss new file mode 100644 index 0000000000000000000000000000000000000000..2f08b635ab83e65808f850ad0f086de344fcd489 GIT binary patch literal 20013 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 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt index 978ac46671e0c5b3f8b337c8478e7a996e5fd73b..9a6e207d9458ee7430e4939b635764d3f48f4756 100644 GIT binary patch delta 120 zcmbQo+Q+tG9#a7?0~bRvLn=caLkXBn29ml!b`e7&P^_3CjUgY%&IO7V0eQASXw0C; pU;xC13?RK}Ky{fwnPQ->WQGEUQm`6Bpu7cCT>;R9VxWl-^#Fci6m9?j delta 7 OcmeBUo5#9g9uoixg#w5G diff --git a/training_content/__init__.py b/training_content/__init__.py new file mode 100644 index 0000000..f1f8bfb --- /dev/null +++ b/training_content/__init__.py @@ -0,0 +1,9 @@ +from .kb import TrainingContentKnowledgeBase +from .service import TrainingContentService +from .gpt import GPT + +__all__ = [ + "TrainingContentService", + "TrainingContentKnowledgeBase", + "GPT" +] diff --git a/training_content/dtos.py b/training_content/dtos.py new file mode 100644 index 0000000..2133f49 --- /dev/null +++ b/training_content/dtos.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel +from typing import List + + +class QueryDTO(BaseModel): + category: str + text: str + + +class DetailsDTO(BaseModel): + exam_id: str + date: int + performance_comment: str + detailed_summary: str + + +class WeakAreaDTO(BaseModel): + area: str + comment: str + + +class TrainingContentDTO(BaseModel): + details: List[DetailsDTO] + weak_areas: List[WeakAreaDTO] + queries: List[QueryDTO] + + +class TipsDTO(BaseModel): + tip_ids: List[str] diff --git a/training_content/gpt.py b/training_content/gpt.py new file mode 100644 index 0000000..b2e1fb6 --- /dev/null +++ b/training_content/gpt.py @@ -0,0 +1,64 @@ +import json +from logging import getLogger + +from typing import List, Optional, Callable + +from openai.types.chat import ChatCompletionMessageParam +from pydantic import BaseModel + + +class GPT: + + def __init__(self, openai_client): + self._client = openai_client + self._default_model = "gpt-4o" + self._logger = getLogger() + + def prediction( + self, + messages: List[ChatCompletionMessageParam], + map_to_model: Callable, + json_scheme: str, + *, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_retries: int = 3 + ) -> List[BaseModel] | BaseModel | str | None: + params = { + "messages": messages, + "response_format": {"type": "json_object"}, + "model": model if model else self._default_model + } + + if temperature: + params["temperature"] = temperature + + attempt = 0 + while attempt < max_retries: + result = self._client.chat.completions.create(**params) + result_content = result.choices[0].message.content + try: + result_json = json.loads(result_content) + return map_to_model(result_json) + except Exception as e: + attempt += 1 + self._logger.info(f"GPT returned malformed response: {result_content}\n {str(e)}") + params["messages"] = [ + { + "role": "user", + "content": ( + "Your previous response wasn't in the json format I've explicitly told you to output. " + f"In your next response, you will fix it and return me just the json I've asked." + ) + }, + { + "role": "user", + "content": ( + f"Previous response: {result_content}\n" + f"JSON format: {json_scheme}" + ) + } + ] + if attempt >= max_retries: + self._logger.error(f"Max retries exceeded!") + return None diff --git a/training_content/kb.py b/training_content/kb.py new file mode 100644 index 0000000..5b17629 --- /dev/null +++ b/training_content/kb.py @@ -0,0 +1,85 @@ +import json +import os +from logging import getLogger +from typing import Dict, List + +import faiss +import pickle + + +class TrainingContentKnowledgeBase: + + def __init__(self, embeddings, path: str = 'pathways_2_rw_with_ids.json'): + self._embedding_model = embeddings + self._tips = None # self._read_json(path) + self._category_metadata = None + self._indices = None + self._logger = getLogger() + + @staticmethod + def _read_json(path: str) -> Dict[str, any]: + with open(path, 'r', encoding="utf-8") as json_file: + return json.loads(json_file.read()) + + def print_category_count(self): + category_tips = {} + for unit in self._tips['units']: + for page in unit['pages']: + for tip in page['tips']: + category = tip['category'].lower().replace(" ", "_") + if category not in category_tips: + category_tips[category] = 0 + else: + category_tips[category] = category_tips[category] + 1 + print(category_tips) + + def create_embeddings_and_save_them(self) -> None: + category_embeddings = {} + category_metadata = {} + + for unit in self._tips['units']: + for page in unit['pages']: + for tip in page['tips']: + category = tip['category'].lower().replace(" ", "_") + if category not in category_embeddings: + category_embeddings[category] = [] + category_metadata[category] = [] + + category_embeddings[category].append(tip['embedding']) + category_metadata[category].append({"id": tip['id'], "text": tip['text']}) + + category_indices = {} + for category, embeddings in category_embeddings.items(): + embeddings_array = self._embedding_model.encode(embeddings) + index = faiss.IndexFlatL2(embeddings_array.shape[1]) + index.add(embeddings_array) + category_indices[category] = index + + faiss.write_index(index, f"./faiss/{category}_tips_index.faiss") + + with open("./faiss/tips_metadata.pkl", "wb") as f: + pickle.dump(category_metadata, f) + + def load_indices_and_metadata( + self, + directory: str = './faiss', + suffix: str = '_tips_index.faiss', + metadata_path: str = './faiss/tips_metadata.pkl' + ): + files = os.listdir(directory) + self._indices = {} + for file in files: + if file.endswith(suffix): + self._indices[file[:-len(suffix)]] = faiss.read_index(f'{directory}/{file}') + self._logger.info(f'Loaded embeddings for {file[:-len(suffix)]} category.') + + with open(metadata_path, 'rb') as f: + self._category_metadata = pickle.load(f) + self._logger.info("Loaded tips metadata") + + def query_knowledge_base(self, query: str, category: str, top_k: int = 5) -> List[Dict[str, str]]: + query_embedding = self._embedding_model.encode([query]) + index = self._indices[category] + D, I = index.search(query_embedding, top_k) + results = [self._category_metadata[category][i] for i in I[0]] + return results diff --git a/training_content/service.py b/training_content/service.py new file mode 100644 index 0000000..08f9c42 --- /dev/null +++ b/training_content/service.py @@ -0,0 +1,278 @@ +from logging import getLogger + +from typing import Dict, List + +from training_content.dtos import TrainingContentDTO, WeakAreaDTO, QueryDTO, DetailsDTO, TipsDTO + + +class TrainingContentService: + + TOOLS = [ + 'critical_thinking', + 'language_for_writing', + 'reading_skills', + 'strategy', + 'words', + 'writing_skills' + ] + # strategy word_link ct_focus reading_skill word_partners writing_skill language_for_writing + + def __init__(self, kb, openai, firestore): + self._training_content_module = kb + self._db = firestore + self._logger = getLogger() + self._llm = openai + + def get_tips(self, stats): + exam_data, exam_map = self._sort_out_solutions(stats) + training_content = self._get_exam_details_and_tips(exam_data) + tips = self._query_kb(training_content.queries) + usefull_tips = self._get_usefull_tips(exam_data, tips) + exam_map = self._merge_exam_map_with_details(exam_map, training_content.details) + + weak_areas = {"weak_areas": []} + for area in training_content.weak_areas: + weak_areas["weak_areas"].append(area.dict()) + + training_doc = { + **exam_map, + **usefull_tips.dict(), + **weak_areas + } + doc_ref = self._db.collection('training').add(training_doc) + return { + "id": doc_ref[1].id + } + + @staticmethod + def _merge_exam_map_with_details(exam_map: Dict[str, any], details: List[DetailsDTO]): + new_exam_map = {"exams": []} + for detail in details: + new_exam_map["exams"].append({ + "id": detail.exam_id, + "date": detail.date, + "performance_comment": detail.performance_comment, + "detailed_summary": detail.detailed_summary, + **exam_map[detail.exam_id] + }) + return new_exam_map + + def _query_kb(self, queries: List[QueryDTO]): + map_categories = { + "critical_thinking": "ct_focus", + "language_for_writing": "language_for_writing", + "reading_skills": "reading_skill", + "strategy": "strategy", + "writing_skills": "writing_skill" + } + + tips = {"tips": []} + for query in queries: + print(f"{query.category} {query.text}") + if query.category == "words": + tips["tips"].extend( + self._training_content_module.query_knowledge_base(query.text, "word_link") + ) + tips["tips"].extend( + self._training_content_module.query_knowledge_base(query.text, "word_partners") + ) + else: + if query.category in map_categories: + tips["tips"].extend( + self._training_content_module.query_knowledge_base(query.text, map_categories[query.category]) + ) + else: + self._logger.info(f"GTP tried to query knowledge base for {query.category} and it doesn't exist.") + return tips + + def _get_exam_details_and_tips(self, exam_data: Dict[str, any]) -> TrainingContentDTO: + json_schema = ( + '{ "details": [{"exam_id": "", "date": 0, "performance_comment": "", "detailed_summary": ""}],' + ' "weak_areas": [{"area": "", "comment": ""}], "queries": [{"text": "", "category": ""}] }' + ) + messages = [ + { + "role": "user", + "content": ( + f"I'm going to provide you with exam data, you will take the exam data and fill this json " + f'schema : {json_schema}. "performance_comment" is a short sentence that describes the ' + 'students\'s performance and main mistakes in a single exam, "detailed_summary" is a detailed ' + 'summary of the student\'s performance, "weak_areas" are identified areas' + ' across all exams which need to be improved upon, for example, area "Grammar and Syntax" comment "Issues' + ' with sentence structure and punctuation.", the "queries" field is where you will write queries ' + 'for tips that will be displayed to the student, the category attribute is a collection of ' + 'embeddings and the text will be the text used to query the knowledge base. The categories are ' + f'the following [{", ".join(self.TOOLS)}].' + ) + }, + { + "role": "user", + "content": f'Exam Data: {str(exam_data)}' + } + ] + return self._llm.prediction(messages, self._map_gpt_response, json_schema) + + def _get_usefull_tips(self, exam_data: Dict[str, any], tips: Dict[str, any]) -> TipsDTO: + json_schema = ( + '{ "tip_ids": [] }' + ) + messages = [ + { + "role": "user", + "content": ( + f"I'm going to provide you with tips and I want you to return to me the tips that " + f"can be usefull for the student that made the exam that I'm going to send you, return " + f"me the tip ids in this json format {json_schema}." + ) + }, + { + "role": "user", + "content": f'Exam Data: {str(exam_data)}' + }, + { + "role": "user", + "content": f'Tips: {str(tips)}' + } + ] + return self._llm.prediction(messages, lambda response: TipsDTO(**response), json_schema) + + @staticmethod + def _map_gpt_response(response: Dict[str, any]) -> TrainingContentDTO: + parsed_response = { + "details": [DetailsDTO(**detail) for detail in response["details"]], + "weak_areas": [WeakAreaDTO(**area) for area in response["weak_areas"]], + "queries": [QueryDTO(**query) for query in response["queries"]] + } + return TrainingContentDTO(**parsed_response) + + def _sort_out_solutions(self, stats): + grouped_stats = {} + for stat in stats: + exam_id = stat["exam"] + module = stat["module"] + if module not in grouped_stats: + grouped_stats[module] = {} + if exam_id not in grouped_stats[module]: + grouped_stats[module][exam_id] = [] + grouped_stats[module][exam_id].append(stat) + + exercises = {} + exam_map = {} + for module, exams in grouped_stats.items(): + exercises[module] = {} + for exam_id, stat_group in exams.items(): + exam = self._get_doc_by_id(module, exam_id) + exercises[module][exam_id] = {"date": None, "exercises": [], "score": None} + exam_total_questions = 0 + exam_total_correct = 0 + for stat in stat_group: + exam_total_questions += stat["score"]["total"] + exam_total_correct += stat["score"]["correct"] + exercises[module][exam_id]["date"] = stat["date"] + + if exam_id not in exam_map: + exam_map[exam_id] = {"stat_ids": [], "score": 0} + exam_map[exam_id]["stat_ids"].append(stat["id"]) + + if module == "listening": + exercises[module][exam_id]["exercises"].extend(self._get_listening_solutions(stat, exam)) + if module == "reading": + exercises[module][exam_id]["exercises"].extend(self._get_reading_solutions(stat, exam)) + if module == "writing": + exercises[module][exam_id]["exercises"].extend(self._get_writing_prompts_and_answers(stat, exam)) + + exam_map[exam_id]["score"] = round((exam_total_correct / exam_total_questions) * 100) + return exercises, exam_map + + def _get_writing_prompts_and_answers(self, stat, exam): + result = [] + try: + exercises = [] + for solution in stat['solutions']: + answer = solution['solution'] + exercise_id = solution['id'] + exercises.append({ + "exercise_id": exercise_id, + "answer": answer + }) + for exercise in exercises: + for exam_exercise in exam["exercises"]: + if exam_exercise["id"] == exercise["exercise_id"]: + result.append({ + "exercise": exam_exercise["prompt"], + "answer": exercise["answer"] + }) + + except KeyError as e: + self._logger.warning(f"Malformed stat object: {str(e)}") + + return result + + def _get_listening_solutions(self, stat, exam): + result = [] + try: + for part in exam["parts"]: + for exercise in part["exercises"]: + if exercise["id"] == stat["exercise"]: + if stat["type"] == "writeBlanks": + result.append({ + "question": exercise["prompt"], + "template": exercise["text"], + "solution": exercise["solutions"], + "answer": stat["solutions"] + }) + if stat["type"] == "multipleChoice": + result.append({ + "question": exercise["prompt"], + "exercise": exercise["questions"], + "answer": stat["solutions"] + }) + except KeyError as e: + self._logger.warning(f"Malformed stat object: {str(e)}") + return result + + def _get_reading_solutions(self, stat, exam): + result = [] + try: + for part in exam["parts"]: + text = part["text"] + for exercise in part["exercises"]: + if exercise["id"] == stat["exercise"]: + if stat["type"] == "fillBlanks": + result.append({ + "text": text, + "question": exercise["prompt"], + "template": exercise["text"], + "words": exercise["words"], + "solutions": exercise["solutions"], + "answer": stat["solutions"] + }) + elif stat["type"] == "writeBlanks": + result.append({ + "text": text, + "question": exercise["prompt"], + "template": exercise["text"], + "solutions": exercise["solutions"], + "answer": stat["solutions"] + }) + else: + # match_sentences + result.append({ + "text": text, + "question": exercise["prompt"], + "sentences": exercise["sentences"], + "options": exercise["options"], + "answer": stat["solutions"] + }) + except KeyError as e: + self._logger.warning(f"Malformed stat object: {str(e)}") + return result + + def _get_doc_by_id(self, collection: str, doc_id: str): + collection_ref = self._db.collection(collection) + doc_ref = collection_ref.document(doc_id) + doc = doc_ref.get() + + if doc.exists: + return doc.to_dict() + return None From a931f06c47736501ba4ecec4527f32829b5df81a Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Wed, 31 Jul 2024 15:03:00 +0100 Subject: [PATCH 34/44] Forgot to add __name__ in getLogger() don't know if it is harmless grabbing the root logger, added __name__ just to be safe --- training_content/gpt.py | 2 +- training_content/kb.py | 2 +- training_content/service.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/training_content/gpt.py b/training_content/gpt.py index b2e1fb6..60020c2 100644 --- a/training_content/gpt.py +++ b/training_content/gpt.py @@ -12,7 +12,7 @@ class GPT: def __init__(self, openai_client): self._client = openai_client self._default_model = "gpt-4o" - self._logger = getLogger() + self._logger = getLogger(__name__) def prediction( self, diff --git a/training_content/kb.py b/training_content/kb.py index 5b17629..dbca899 100644 --- a/training_content/kb.py +++ b/training_content/kb.py @@ -14,7 +14,7 @@ class TrainingContentKnowledgeBase: self._tips = None # self._read_json(path) self._category_metadata = None self._indices = None - self._logger = getLogger() + self._logger = getLogger(__name__) @staticmethod def _read_json(path: str) -> Dict[str, any]: diff --git a/training_content/service.py b/training_content/service.py index 08f9c42..5259228 100644 --- a/training_content/service.py +++ b/training_content/service.py @@ -20,7 +20,7 @@ class TrainingContentService: def __init__(self, kb, openai, firestore): self._training_content_module = kb self._db = firestore - self._logger = getLogger() + self._logger = getLogger(__name__) self._llm = openai def get_tips(self, stats): From 034be25e8e5288a10e33d3786d1e7f325b1116c2 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Thu, 1 Aug 2024 20:49:22 +0100 Subject: [PATCH 35/44] Added created_at and score to training docs --- training_content/service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/training_content/service.py b/training_content/service.py index 5259228..480b773 100644 --- a/training_content/service.py +++ b/training_content/service.py @@ -1,3 +1,4 @@ +from datetime import datetime from logging import getLogger from typing import Dict, List @@ -35,6 +36,7 @@ class TrainingContentService: weak_areas["weak_areas"].append(area.dict()) training_doc = { + 'created_at': int(datetime.now().timestamp() * 1000), **exam_map, **usefull_tips.dict(), **weak_areas @@ -182,6 +184,7 @@ class TrainingContentService: exercises[module][exam_id]["exercises"].extend(self._get_writing_prompts_and_answers(stat, exam)) exam_map[exam_id]["score"] = round((exam_total_correct / exam_total_questions) * 100) + exam_map[exam_id]["module"] = module return exercises, exam_map def _get_writing_prompts_and_answers(self, stat, exam): From 7144a3f3caa6d7b4798a056171064700285c6527 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Mon, 5 Aug 2024 21:41:49 +0100 Subject: [PATCH 36/44] Supports now 1 exam multiple exercises, and level exercises --- training_content/service.py | 82 ++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/training_content/service.py b/training_content/service.py index 480b773..c543d44 100644 --- a/training_content/service.py +++ b/training_content/service.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from logging import getLogger @@ -42,8 +43,10 @@ class TrainingContentService: **weak_areas } doc_ref = self._db.collection('training').add(training_doc) + return { "id": doc_ref[1].id + # "id": "ZYSDZPk4eUqxhMm4Oz7L" } @staticmethod @@ -70,7 +73,6 @@ class TrainingContentService: tips = {"tips": []} for query in queries: - print(f"{query.category} {query.text}") if query.category == "words": tips["tips"].extend( self._training_content_module.query_knowledge_base(query.text, "word_link") @@ -104,7 +106,8 @@ class TrainingContentService: ' with sentence structure and punctuation.", the "queries" field is where you will write queries ' 'for tips that will be displayed to the student, the category attribute is a collection of ' 'embeddings and the text will be the text used to query the knowledge base. The categories are ' - f'the following [{", ".join(self.TOOLS)}].' + f'the following [{", ".join(self.TOOLS)}]. The exam data will be a json where the key of the field ' + '"exams" is the exam id, an exam can be composed of multiple modules or single modules.' ) }, { @@ -150,42 +153,60 @@ class TrainingContentService: def _sort_out_solutions(self, stats): grouped_stats = {} for stat in stats: - exam_id = stat["exam"] + session_key = f'{str(stat["date"])}-{stat["user"]}' module = stat["module"] - if module not in grouped_stats: - grouped_stats[module] = {} - if exam_id not in grouped_stats[module]: - grouped_stats[module][exam_id] = [] - grouped_stats[module][exam_id].append(stat) + exam_id = stat["exam"] + + if session_key not in grouped_stats: + grouped_stats[session_key] = {} + if module not in grouped_stats[session_key]: + grouped_stats[session_key][module] = { + "stats": [], + "exam_id": exam_id + } + grouped_stats[session_key][module]["stats"].append(stat) exercises = {} exam_map = {} - for module, exams in grouped_stats.items(): - exercises[module] = {} - for exam_id, stat_group in exams.items(): - exam = self._get_doc_by_id(module, exam_id) - exercises[module][exam_id] = {"date": None, "exercises": [], "score": None} + for session_key, modules in grouped_stats.items(): + exercises[session_key] = {} + for module, module_stats in modules.items(): + exercises[session_key][module] = {} + + exam_id = module_stats["exam_id"] + if exam_id not in exercises[session_key][module]: + exercises[session_key][module][exam_id] = {"date": None, "exercises": []} + exam_total_questions = 0 exam_total_correct = 0 - for stat in stat_group: + + for stat in module_stats["stats"]: exam_total_questions += stat["score"]["total"] exam_total_correct += stat["score"]["correct"] - exercises[module][exam_id]["date"] = stat["date"] + exercises[session_key][module][exam_id]["date"] = stat["date"] - if exam_id not in exam_map: - exam_map[exam_id] = {"stat_ids": [], "score": 0} - exam_map[exam_id]["stat_ids"].append(stat["id"]) + if session_key not in exam_map: + exam_map[session_key] = {"stat_ids": [], "score": 0} + exam_map[session_key]["stat_ids"].append(stat["id"]) + exam = self._get_doc_by_id(module, exam_id) if module == "listening": - exercises[module][exam_id]["exercises"].extend(self._get_listening_solutions(stat, exam)) - if module == "reading": - exercises[module][exam_id]["exercises"].extend(self._get_reading_solutions(stat, exam)) - if module == "writing": - exercises[module][exam_id]["exercises"].extend(self._get_writing_prompts_and_answers(stat, exam)) + exercises[session_key][module][exam_id]["exercises"].extend( + self._get_listening_solutions(stat, exam)) + elif module == "reading": + exercises[session_key][module][exam_id]["exercises"].extend( + self._get_reading_solutions(stat, exam)) + elif module == "writing": + exercises[session_key][module][exam_id]["exercises"].extend( + self._get_writing_prompts_and_answers(stat, exam)) + elif module == "level": # same structure as listening + exercises[session_key][module][exam_id]["exercises"].extend( + self._get_listening_solutions(stat, exam)) - exam_map[exam_id]["score"] = round((exam_total_correct / exam_total_questions) * 100) - exam_map[exam_id]["module"] = module - return exercises, exam_map + exam_map[session_key]["score"] = round((exam_total_correct / exam_total_questions) * 100) + exam_map[session_key]["module"] = module + + return {"exams": exercises}, exam_map def _get_writing_prompts_and_answers(self, stat, exam): result = [] @@ -258,8 +279,13 @@ class TrainingContentService: "solutions": exercise["solutions"], "answer": stat["solutions"] }) - else: - # match_sentences + elif stat["type"] == "trueFalse": + result.append({ + "text": text, + "questions": exercise["questions"], + "answer": stat["solutions"] + }) + elif stat["type"] == "matchSentences": result.append({ "text": text, "question": exercise["prompt"], From 3ad411ed716202dca353c2b8689b3193ef0fd3b0 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Mon, 5 Aug 2024 21:47:17 +0100 Subject: [PATCH 37/44] Forgot to remove some debugging lines --- training_content/service.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/training_content/service.py b/training_content/service.py index c543d44..f1dc504 100644 --- a/training_content/service.py +++ b/training_content/service.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from logging import getLogger @@ -46,7 +45,6 @@ class TrainingContentService: return { "id": doc_ref[1].id - # "id": "ZYSDZPk4eUqxhMm4Oz7L" } @staticmethod From 470f4cc83b5a1b5076e21fb8ab3363816e13ccf5 Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Mon, 5 Aug 2024 21:57:42 +0100 Subject: [PATCH 38/44] Minor speaking improvements. --- app.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/app.py b/app.py index a67a0fa..51d4048 100644 --- a/app.py +++ b/app.py @@ -314,6 +314,11 @@ def grade_writing_task_1(): 'Additionally, provide a detailed commentary highlighting both strengths and ' 'weaknesses in the response. ' '\n Question: "' + question + '" \n Answer: "' + answer + '"') + }, + { + "role": "user", + "content": ('Refer to the parts of the letter as: "Greeting Opener", "bullet 1", "bullet 2", ' + '"bullet 3", "closer (restate the purpose of the letter)", "closing greeting"') } ] token_count = count_total_tokens(messages) @@ -713,7 +718,8 @@ def get_speaking_task_1_question(): "first_topic": "topic 1", "second_topic": "topic 2", "questions": [ - "Introductory question, should start with a greeting and introduce a question about the first topic, starting the topic with 'Let's talk about x' and then the question.", + "Introductory question about the first topic, starting the topic with 'Let's talk about x' and then the " + "question.", "Follow up question about the first topic", "Follow up question about the first topic", "Question about second topic", @@ -731,21 +737,25 @@ def get_speaking_task_1_question(): { "role": "user", "content": ( - 'Craft 5 simple questions of easy difficulty for IELTS Speaking Part 1 ' + 'Craft 5 simple and single questions of easy difficulty for IELTS Speaking Part 1 ' 'that encourages candidates to delve deeply into ' 'personal experiences, preferences, or insights on the topic ' - 'of "' + first_topic + '" and the topic of "' + second_topic + '". Instruct the candidate ' - 'to offer not only detailed ' - 'descriptions but also provide ' - 'nuanced explanations, examples, ' - 'or anecdotes to enrich their response. ' - 'Make sure that the generated question ' - 'does not contain forbidden subjects in ' + 'of "' + first_topic + '" and the topic of "' + second_topic + '". ' + 'Make sure that the generated ' + 'question' + 'does not contain forbidden ' + 'subjects in' 'muslim countries.') }, { "role": "user", - "content": 'The questions should lead to the usage of 4 verb tenses (present perfect, present, past and future).' + "content": 'The questions should lead to the usage of 4 verb tenses (present perfect, present, ' + 'past and future).' + }, + { + "role": "user", + "content": 'They must be 1 single question each and not be double-barreled questions.' + } ] token_count = count_total_tokens(messages) @@ -785,7 +795,8 @@ def grade_speaking_task_2(): "task_response": { "Fluency and Coherence": { "grade": 0.0, - "comment": "extensive comment about fluency and coherence, use examples to justify the grade awarded." + "comment": "extensive comment about fluency and coherence, use examples to justify the grade " + "awarded." }, "Lexical Resource": { "grade": 0.0, @@ -793,11 +804,13 @@ def grade_speaking_task_2(): }, "Grammatical Range and Accuracy": { "grade": 0.0, - "comment": "extensive comment about grammatical range and accuracy, use examples to justify the grade awarded." + "comment": "extensive comment about grammatical range and accuracy, use examples to justify the " + "grade awarded." }, "Pronunciation": { "grade": 0.0, - "comment": "extensive comment about pronunciation on the transcribed answer, use examples to justify the grade awarded." + "comment": "extensive comment about pronunciation on the transcribed answer, use examples to " + "justify the grade awarded." } } } @@ -974,11 +987,16 @@ def get_speaking_task_3_question(): { "role": "user", "content": ( - 'Formulate a set of 5 questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' + 'Formulate a set of 5 single questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' 'meaningful discussion on the topic of "' + topic + '". Provide inquiries, ensuring ' 'they explore various aspects, perspectives, and implications related to the topic.' 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') + }, + { + "role": "user", + "content": 'They must be 1 single question each and not be double-barreled questions.' + } ] token_count = count_total_tokens(messages) @@ -1203,7 +1221,7 @@ def save_speaking(): def generate_video_1(): try: data = request.get_json() - sp3_questions = [] + sp1_questions = [] avatar = data.get("avatar", random.choice(list(AvatarEnum)).value) request_id = str(uuid.uuid4()) @@ -1211,8 +1229,24 @@ def generate_video_1(): "Use this id to track the logs: " + str(request_id) + " - Request data: " + str( request.get_json())) + id_to_name = { + "5912afa7c77c47d3883af3d874047aaf": "MATTHEW", + "9e58d96a383e4568a7f1e49df549e0e4": "VERA", + "d2cdd9c0379a4d06ae2afb6e5039bd0c": "EDWARD", + "045cb5dcd00042b3a1e4f3bc1c12176b": "TANYA", + "1ae1e5396cc444bfad332155fdb7a934": "KAYLA", + "0ee6aa7cc1084063a630ae514fccaa31": "JEROME", + "5772cff935844516ad7eeff21f839e43": "TYLER", + + } + + standard_questions = [ + "Hello my name is " + id_to_name.get(avatar) + ", what is yours?", + "Do you work or do you study?" + ] + questions = standard_questions + data["questions"] logging.info("POST - generate_video_1 - " + str(request_id) + " - Creating videos for speaking part 1.") - for question in data["questions"]: + for question in questions: logging.info("POST - generate_video_1 - " + str(request_id) + " - Creating video for question: " + question) result = create_video(question, avatar) logging.info("POST - generate_video_1 - " + str(request_id) + " - Video created: " + result) @@ -1231,13 +1265,13 @@ def generate_video_1(): "video_path": firebase_file_path, "video_url": url } - sp3_questions.append(video) + sp1_questions.append(video) else: logging.error("POST - generate_video_1 - " + str( request_id) + " - Failed to create video for part 1 question: " + question) response = { - "prompts": sp3_questions, + "prompts": sp1_questions, "first_title": data["first_topic"], "second_title": data["second_topic"], "type": "interactiveSpeaking", @@ -1607,6 +1641,39 @@ def get_custom_level(): return response +@app.route('/grade_short_answers', methods=['POST']) +@jwt_required() +def grade_short_answers(): + data = request.get_json() + + json_format = { + "exercises": [ + { + "id": 1, + "correct": True, + "correct_answer": " correct answer if wrong" + } + ] + } + + try: + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + }, + { + "role": "user", + "content": 'Grade these answers according to the text content and write a correct answer if they are wrong. Text, questions and answers:\n ' + str(data) + + } + ] + token_count = count_total_tokens(messages) + response = make_openai_call(GPT_4_O, messages, token_count, GEN_FIELDS, GEN_QUESTION_TEMPERATURE) + return response + except Exception as e: + return str(e) @app.route('/fetch_tips', methods=['POST']) @jwt_required() From beccf8b501c6d77a229bb12b49c6b11031646e6d Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Tue, 6 Aug 2024 20:28:56 +0100 Subject: [PATCH 39/44] Change model on speaking 2 grading to 4o. --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 51d4048..b022293 100644 --- a/app.py +++ b/app.py @@ -839,7 +839,7 @@ def grade_speaking_task_2(): token_count = count_total_tokens(messages) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Requesting grading of the answer.") - response = make_openai_call(GPT_3_5_TURBO, messages, token_count, ["comment"], + response = make_openai_call(GPT_4_O, messages, token_count, ["comment"], GRADING_TEMPERATURE) logging.info("POST - speaking_task_2 - " + str(request_id) + " - Answer graded: " + str(response)) From eeaa04f8562c7cd697a29e7948322261ccddd880 Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Wed, 7 Aug 2024 10:19:56 +0100 Subject: [PATCH 40/44] Added suport for speaking exercises in training content --- training_content/service.py | 42 ++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/training_content/service.py b/training_content/service.py index f1dc504..67d4434 100644 --- a/training_content/service.py +++ b/training_content/service.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from logging import getLogger @@ -196,10 +197,16 @@ class TrainingContentService: self._get_reading_solutions(stat, exam)) elif module == "writing": exercises[session_key][module][exam_id]["exercises"].extend( - self._get_writing_prompts_and_answers(stat, exam)) + self._get_writing_prompts_and_answers(stat, exam) + ) + elif module == "speaking": + exercises[session_key][module][exam_id]["exercises"].extend( + self._get_speaking_solutions(stat, exam) + ) elif module == "level": # same structure as listening exercises[session_key][module][exam_id]["exercises"].extend( - self._get_listening_solutions(stat, exam)) + self._get_listening_solutions(stat, exam) + ) exam_map[session_key]["score"] = round((exam_total_correct / exam_total_questions) * 100) exam_map[session_key]["module"] = module @@ -243,7 +250,7 @@ class TrainingContentService: "solution": exercise["solutions"], "answer": stat["solutions"] }) - if stat["type"] == "multipleChoice": + elif stat["type"] == "multipleChoice": result.append({ "question": exercise["prompt"], "exercise": exercise["questions"], @@ -253,6 +260,35 @@ class TrainingContentService: self._logger.warning(f"Malformed stat object: {str(e)}") return result + def _get_speaking_solutions(self, stat, exam): + result = {} + try: + result = { + "comments": { + key: value['comment'] for key, value in stat['solutions'][0]['evaluation']['task_response'].items()} + , + "exercises": {} + } + + for exercise in exam["exercises"]: + if exercise["id"] == stat["exercise"]: + if stat["type"] == "interactiveSpeaking": + for i in range(len(exercise["prompts"])): + result["exercises"][f"exercise_{i+1}"] = { + "question": exercise["prompts"][i]["text"] + } + for i in range(len(exercise["prompts"])): + answer = stat['solutions'][0]["evaluation"].get(f'transcript_{i+1}', '') + result["exercises"][f"exercise_{i+1}"]["answer"] = answer + elif stat["type"] == "speaking": + result["exercises"]["exercise_1"] = { + "question": exercise["text"], + "answer": stat['solutions'][0]["evaluation"].get(f'transcript', '') + } + except KeyError as e: + self._logger.warning(f"Malformed stat object: {str(e)}") + return [result] + def _get_reading_solutions(self, stat, exam): result = [] try: From d68617f33b22015bc5af994fa099a5c340de7ced Mon Sep 17 00:00:00 2001 From: Cristiano Ferreira Date: Thu, 15 Aug 2024 13:58:07 +0100 Subject: [PATCH 41/44] Add regular ielts modules to custom level. --- app.py | 506 +++++++++++++++++++++----------------------- helper/exercises.py | 366 +++++++++++++++++++++++++++++--- 2 files changed, 573 insertions(+), 299 deletions(-) diff --git a/app.py b/app.py index b022293..a28fd30 100644 --- a/app.py +++ b/app.py @@ -65,25 +65,7 @@ def get_listening_section_1_question(): req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_1_EXERCISE_TYPES, 1) - - number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_1_EXERCISES, len(req_exercises)) - - processed_conversation = generate_listening_1_conversation(topic) - - app.logger.info("Generated conversation: " + str(processed_conversation)) - - start_id = 1 - exercises = generate_listening_conversation_exercises(parse_conversation(processed_conversation), - req_exercises, - number_of_exercises_q, - start_id, difficulty) - return { - "exercises": exercises, - "text": processed_conversation, - "difficulty": difficulty - } + return gen_listening_section_1(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -98,22 +80,7 @@ def get_listening_section_2_question(): req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_2_EXERCISE_TYPES, 2) - - number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_2_EXERCISES, len(req_exercises)) - - monologue = generate_listening_2_monologue(topic) - - app.logger.info("Generated monologue: " + str(monologue)) - start_id = 11 - exercises = generate_listening_monologue_exercises(str(monologue), req_exercises, number_of_exercises_q, - start_id, difficulty) - return { - "exercises": exercises, - "text": monologue, - "difficulty": difficulty - } + return gen_listening_section_2(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -128,24 +95,7 @@ def get_listening_section_3_question(): req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_3_EXERCISE_TYPES, 1) - - number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_3_EXERCISES, len(req_exercises)) - - processed_conversation = generate_listening_3_conversation(topic) - - app.logger.info("Generated conversation: " + str(processed_conversation)) - - start_id = 21 - exercises = generate_listening_conversation_exercises(parse_conversation(processed_conversation), req_exercises, - number_of_exercises_q, - start_id, difficulty) - return { - "exercises": exercises, - "text": processed_conversation, - "difficulty": difficulty - } + return gen_listening_section_3(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -160,22 +110,7 @@ def get_listening_section_4_question(): req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - if (len(req_exercises) == 0): - req_exercises = random.sample(LISTENING_EXERCISE_TYPES, 2) - - number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_4_EXERCISES, len(req_exercises)) - - monologue = generate_listening_4_monologue(topic) - - app.logger.info("Generated monologue: " + str(monologue)) - start_id = 31 - exercises = generate_listening_monologue_exercises(str(monologue), req_exercises, number_of_exercises_q, - start_id, difficulty) - return { - "exercises": exercises, - "text": monologue, - "difficulty": difficulty - } + return gen_listening_section_4(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -342,37 +277,7 @@ def get_writing_task_1_general_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) topic = request.args.get("topic", default=random.choice(mti_topics)) try: - messages = [ - { - "role": "system", - "content": ('You are a helpful assistant designed to output JSON on this format: ' - '{"prompt": "prompt content"}') - }, - { - "role": "user", - "content": ('Craft a prompt for an IELTS Writing Task 1 General Training exercise that instructs the ' - 'student to compose a letter. The prompt should present a specific scenario or situation, ' - 'based on the topic of "' + topic + '", requiring the student to provide information, ' - 'advice, or instructions within the letter. ' - 'Make sure that the generated prompt is ' - 'of ' + difficulty + 'difficulty and does not contain ' - 'forbidden subjects in muslim ' - 'countries.') - }, - { - "role": "user", - "content": 'The prompt should end with "In the letter you should" followed by 3 bullet points of what ' - 'the answer should include.' - } - ] - token_count = count_total_tokens(messages) - response = make_openai_call(GPT_3_5_TURBO, messages, token_count, "prompt", - GEN_QUESTION_TEMPERATURE) - return { - "question": add_newline_before_hyphen(response["prompt"].strip()), - "difficulty": difficulty, - "topic": topic - } + return gen_writing_task_1(topic, difficulty) except Exception as e: return str(e) @@ -507,32 +412,7 @@ def get_writing_task_2_general_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) topic = request.args.get("topic", default=random.choice(mti_topics)) try: - messages = [ - { - "role": "system", - "content": ('You are a helpful assistant designed to output JSON on this format: ' - '{"prompt": "prompt content"}') - }, - { - "role": "user", - "content": ( - 'Craft a comprehensive question of ' + difficulty + 'difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate ' - 'to delve into an in-depth analysis of contrasting perspectives on the topic of "' + topic + '". ' - 'The candidate should be asked to discuss the strengths and weaknesses of both viewpoints.') - }, - { - "role": "user", - "content": 'The question should lead to an answer with either "theories", "complicated information" or ' - 'be "very descriptive" on the topic.' - } - ] - token_count = count_total_tokens(messages) - response = make_openai_call(GPT_4_O, messages, token_count, "prompt", GEN_QUESTION_TEMPERATURE) - return { - "question": response["prompt"].strip(), - "difficulty": difficulty, - "topic": topic - } + return gen_writing_task_2(topic, difficulty) except Exception as e: return str(e) @@ -714,56 +594,8 @@ def get_speaking_task_1_question(): first_topic = request.args.get("first_topic", default=random.choice(mti_topics)) second_topic = request.args.get("second_topic", default=random.choice(mti_topics)) - json_format = { - "first_topic": "topic 1", - "second_topic": "topic 2", - "questions": [ - "Introductory question about the first topic, starting the topic with 'Let's talk about x' and then the " - "question.", - "Follow up question about the first topic", - "Follow up question about the first topic", - "Question about second topic", - "Follow up question about the second topic", - ] - } - try: - messages = [ - { - "role": "system", - "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) - }, - { - "role": "user", - "content": ( - 'Craft 5 simple and single questions of easy difficulty for IELTS Speaking Part 1 ' - 'that encourages candidates to delve deeply into ' - 'personal experiences, preferences, or insights on the topic ' - 'of "' + first_topic + '" and the topic of "' + second_topic + '". ' - 'Make sure that the generated ' - 'question' - 'does not contain forbidden ' - 'subjects in' - 'muslim countries.') - }, - { - "role": "user", - "content": 'The questions should lead to the usage of 4 verb tenses (present perfect, present, ' - 'past and future).' - }, - { - "role": "user", - "content": 'They must be 1 single question each and not be double-barreled questions.' - - } - ] - token_count = count_total_tokens(messages) - response = make_openai_call(GPT_4_O, messages, token_count, ["first_topic"], - GEN_QUESTION_TEMPERATURE) - response["type"] = 1 - response["difficulty"] = difficulty - return response + return gen_speaking_part_1(first_topic, second_topic, difficulty) except Exception as e: return str(e) @@ -913,50 +745,8 @@ def get_speaking_task_2_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) topic = request.args.get("topic", default=random.choice(mti_topics)) - json_format = { - "topic": "topic", - "question": "question", - "prompts": [ - "prompt_1", - "prompt_2", - "prompt_3" - ], - "suffix": "And explain why..." - } - try: - messages = [ - { - "role": "system", - "content": 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format) - }, - { - "role": "user", - "content": ( - 'Create a question of medium difficulty for IELTS Speaking Part 2 ' - 'that encourages candidates to narrate a ' - 'personal experience or story related to the topic ' - 'of "' + topic + '". Include 3 prompts that ' - 'guide the candidate to describe ' - 'specific aspects of the experience, ' - 'such as details about the situation, ' - 'their actions, and the reasons it left a ' - 'lasting impression. Make sure that the ' - 'generated question does not contain ' - 'forbidden subjects in muslim countries.') - }, - { - "role": "user", - "content": 'The prompts must not be questions. Also include a suffix like the ones in the IELTS exams ' - 'that start with "And explain why".' - } - ] - token_count = count_total_tokens(messages) - response = make_openai_call(GPT_4_O, messages, token_count, GEN_FIELDS, GEN_QUESTION_TEMPERATURE) - response["type"] = 2 - response["difficulty"] = difficulty - response["topic"] = topic - return response + return gen_speaking_part_2(topic, difficulty) except Exception as e: return str(e) @@ -967,47 +757,8 @@ def get_speaking_task_3_question(): difficulty = request.args.get("difficulty", default=random.choice(difficulties)) topic = request.args.get("topic", default=random.choice(mti_topics)) - json_format = { - "topic": "topic", - "questions": [ - "Introductory question about the topic.", - "Follow up question about the topic", - "Follow up question about the topic", - "Follow up question about the topic", - "Follow up question about the topic" - ] - } try: - messages = [ - { - "role": "system", - "content": ( - 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) - }, - { - "role": "user", - "content": ( - 'Formulate a set of 5 single questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' - 'meaningful discussion on the topic of "' + topic + '". Provide inquiries, ensuring ' - 'they explore various aspects, perspectives, and implications related to the topic.' - 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') - - }, - { - "role": "user", - "content": 'They must be 1 single question each and not be double-barreled questions.' - - } - ] - token_count = count_total_tokens(messages) - response = make_openai_call(GPT_4_O, messages, token_count, GEN_FIELDS, GEN_QUESTION_TEMPERATURE) - # Remove the numbers from the questions only if the string starts with a number - response["questions"] = [re.sub(r"^\d+\.\s*", "", question) if re.match(r"^\d+\.", question) else question for - question in response["questions"]] - response["type"] = 3 - response["difficulty"] = difficulty - response["topic"] = topic - return response + return gen_speaking_part_3(topic, difficulty) except Exception as e: return str(e) @@ -1402,7 +1153,7 @@ def get_reading_passage_1_question(): topic = request.args.get('topic', default=random.choice(topics)) req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - return gen_reading_passage_1(topic, req_exercises, difficulty) + return gen_reading_passage_1(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -1415,7 +1166,7 @@ def get_reading_passage_2_question(): topic = request.args.get('topic', default=random.choice(topics)) req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - return gen_reading_passage_2(topic, req_exercises, difficulty) + return gen_reading_passage_2(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -1428,7 +1179,7 @@ def get_reading_passage_3_question(): topic = request.args.get('topic', default=random.choice(topics)) req_exercises = request.args.getlist('exercises') difficulty = request.args.get("difficulty", default=random.choice(difficulties)) - return gen_reading_passage_3(topic, req_exercises, difficulty) + return gen_reading_passage_3(topic, difficulty, req_exercises) except Exception as e: return str(e) @@ -1560,6 +1311,18 @@ class CustomLevelExerciseTypes(Enum): MULTIPLE_CHOICE_UNDERLINED = "multiple_choice_underlined" BLANK_SPACE_TEXT = "blank_space_text" READING_PASSAGE_UTAS = "reading_passage_utas" + WRITING_LETTER = "writing_letter" + WRITING_2 = "writing_2" + SPEAKING_1 = "speaking_1" + SPEAKING_2 = "speaking_2" + SPEAKING_3 = "speaking_3" + READING_1 = "reading_1" + READING_2 = "reading_2" + READING_3 = "reading_3" + LISTENING_1 = "listening_1" + LISTENING_2 = "listening_2" + LISTENING_3 = "listening_3" + LISTENING_4 = "listening_4" @app.route('/custom_level', methods=['GET']) @@ -1574,11 +1337,24 @@ def get_custom_level(): } for i in range(1, nr_exercises + 1, 1): exercise_type = request.args.get('exercise_' + str(i) + '_type') + exercise_difficulty = request.args.get('exercise_' + str(i) + '_difficulty', + random.choice(['easy', 'medium', 'hard'])) exercise_qty = int(request.args.get('exercise_' + str(i) + '_qty', -1)) exercise_topic = request.args.get('exercise_' + str(i) + '_topic', random.choice(topics)) + exercise_topic_2 = request.args.get('exercise_' + str(i) + '_topic_2', random.choice(topics)) exercise_text_size = int(request.args.get('exercise_' + str(i) + '_text_size', 700)) exercise_sa_qty = int(request.args.get('exercise_' + str(i) + '_sa_qty', -1)) exercise_mc_qty = int(request.args.get('exercise_' + str(i) + '_mc_qty', -1)) + exercise_mc3_qty = int(request.args.get('exercise_' + str(i) + '_mc3_qty', -1)) + exercise_fillblanks_qty = int(request.args.get('exercise_' + str(i) + '_fillblanks_qty', -1)) + exercise_writeblanks_qty = int(request.args.get('exercise_' + str(i) + '_writeblanks_qty', -1)) + exercise_writeblanksquestions_qty = int( + request.args.get('exercise_' + str(i) + '_writeblanksquestions_qty', -1)) + exercise_writeblanksfill_qty = int(request.args.get('exercise_' + str(i) + '_writeblanksfill_qty', -1)) + exercise_writeblanksform_qty = int(request.args.get('exercise_' + str(i) + '_writeblanksform_qty', -1)) + exercise_truefalse_qty = int(request.args.get('exercise_' + str(i) + '_truefalse_qty', -1)) + exercise_paragraphmatch_qty = int(request.args.get('exercise_' + str(i) + '_paragraphmatch_qty', -1)) + exercise_ideamatch_qty = int(request.args.get('exercise_' + str(i) + '_ideamatch_qty', -1)) if exercise_type == CustomLevelExerciseTypes.MULTIPLE_CHOICE_4.value: response["exercises"]["exercise_" + str(i)] = {} @@ -1592,7 +1368,7 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["questions"].extend( generate_level_mc(exercise_id, qty, - response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) + response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) exercise_id = exercise_id + qty exercise_qty = exercise_qty - qty @@ -1608,7 +1384,8 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["questions"].extend( gen_multiple_choice_blank_space_utas(qty, exercise_id, - response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) + response["exercises"]["exercise_" + str(i)]["questions"])[ + "questions"]) exercise_id = exercise_id + qty exercise_qty = exercise_qty - qty @@ -1624,7 +1401,8 @@ def get_custom_level(): response["exercises"]["exercise_" + str(i)]["questions"].extend( gen_multiple_choice_underlined_utas(qty, exercise_id, - response["exercises"]["exercise_" + str(i)]["questions"])["questions"]) + response["exercises"]["exercise_" + str(i)]["questions"])[ + "questions"]) exercise_id = exercise_id + qty exercise_qty = exercise_qty - qty @@ -1638,9 +1416,205 @@ def get_custom_level(): exercise_mc_qty, exercise_topic) response["exercises"]["exercise_" + str(i)]["type"] = "readingExercises" exercise_id = exercise_id + exercise_qty + elif exercise_type == CustomLevelExerciseTypes.WRITING_LETTER.value: + response["exercises"]["exercise_" + str(i)] = gen_writing_task_1(exercise_topic, exercise_difficulty) + response["exercises"]["exercise_" + str(i)]["type"] = "writing" + exercise_id = exercise_id + 1 + elif exercise_type == CustomLevelExerciseTypes.WRITING_2.value: + response["exercises"]["exercise_" + str(i)] = gen_writing_task_2(exercise_topic, exercise_difficulty) + response["exercises"]["exercise_" + str(i)]["type"] = "writing" + exercise_id = exercise_id + 1 + elif exercise_type == CustomLevelExerciseTypes.SPEAKING_1.value: + response["exercises"]["exercise_" + str(i)] = ( + gen_speaking_part_1(exercise_topic, exercise_topic_2, exercise_difficulty)) + response["exercises"]["exercise_" + str(i)]["type"] = "interactiveSpeaking" + exercise_id = exercise_id + 1 + elif exercise_type == CustomLevelExerciseTypes.SPEAKING_2.value: + response["exercises"]["exercise_" + str(i)] = gen_speaking_part_2(exercise_topic, exercise_difficulty) + response["exercises"]["exercise_" + str(i)]["type"] = "speaking" + exercise_id = exercise_id + 1 + elif exercise_type == CustomLevelExerciseTypes.SPEAKING_3.value: + response["exercises"]["exercise_" + str(i)] = gen_speaking_part_3(exercise_topic, exercise_difficulty) + response["exercises"]["exercise_" + str(i)]["type"] = "interactiveSpeaking" + exercise_id = exercise_id + 1 + elif exercise_type == CustomLevelExerciseTypes.READING_1.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_fillblanks_qty != -1: + exercises.append('fillBlanks') + exercise_qty_q.put(exercise_fillblanks_qty) + total_qty = total_qty + exercise_fillblanks_qty + if exercise_writeblanks_qty != -1: + exercises.append('writeBlanks') + exercise_qty_q.put(exercise_writeblanks_qty) + total_qty = total_qty + exercise_writeblanks_qty + if exercise_truefalse_qty != -1: + exercises.append('trueFalse') + exercise_qty_q.put(exercise_truefalse_qty) + total_qty = total_qty + exercise_truefalse_qty + if exercise_paragraphmatch_qty != -1: + exercises.append('paragraphMatch') + exercise_qty_q.put(exercise_paragraphmatch_qty) + total_qty = total_qty + exercise_paragraphmatch_qty + + response["exercises"]["exercise_" + str(i)] = gen_reading_passage_1(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "reading" + + exercise_id = exercise_id + total_qty + elif exercise_type == CustomLevelExerciseTypes.READING_2.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_fillblanks_qty != -1: + exercises.append('fillBlanks') + exercise_qty_q.put(exercise_fillblanks_qty) + total_qty = total_qty + exercise_fillblanks_qty + if exercise_writeblanks_qty != -1: + exercises.append('writeBlanks') + exercise_qty_q.put(exercise_writeblanks_qty) + total_qty = total_qty + exercise_writeblanks_qty + if exercise_truefalse_qty != -1: + exercises.append('trueFalse') + exercise_qty_q.put(exercise_truefalse_qty) + total_qty = total_qty + exercise_truefalse_qty + if exercise_paragraphmatch_qty != -1: + exercises.append('paragraphMatch') + exercise_qty_q.put(exercise_paragraphmatch_qty) + total_qty = total_qty + exercise_paragraphmatch_qty + + response["exercises"]["exercise_" + str(i)] = gen_reading_passage_2(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "reading" + + exercise_id = exercise_id + total_qty + elif exercise_type == CustomLevelExerciseTypes.READING_3.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_fillblanks_qty != -1: + exercises.append('fillBlanks') + exercise_qty_q.put(exercise_fillblanks_qty) + total_qty = total_qty + exercise_fillblanks_qty + if exercise_writeblanks_qty != -1: + exercises.append('writeBlanks') + exercise_qty_q.put(exercise_writeblanks_qty) + total_qty = total_qty + exercise_writeblanks_qty + if exercise_truefalse_qty != -1: + exercises.append('trueFalse') + exercise_qty_q.put(exercise_truefalse_qty) + total_qty = total_qty + exercise_truefalse_qty + if exercise_paragraphmatch_qty != -1: + exercises.append('paragraphMatch') + exercise_qty_q.put(exercise_paragraphmatch_qty) + total_qty = total_qty + exercise_paragraphmatch_qty + if exercise_ideamatch_qty != -1: + exercises.append('ideaMatch') + exercise_qty_q.put(exercise_ideamatch_qty) + total_qty = total_qty + exercise_ideamatch_qty + + response["exercises"]["exercise_" + str(i)] = gen_reading_passage_3(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "reading" + + exercise_id = exercise_id + total_qty + elif exercise_type == CustomLevelExerciseTypes.LISTENING_1.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_mc_qty != -1: + exercises.append('multipleChoice') + exercise_qty_q.put(exercise_mc_qty) + total_qty = total_qty + exercise_mc_qty + if exercise_writeblanksquestions_qty != -1: + exercises.append('writeBlanksQuestions') + exercise_qty_q.put(exercise_writeblanksquestions_qty) + total_qty = total_qty + exercise_writeblanksquestions_qty + if exercise_writeblanksfill_qty != -1: + exercises.append('writeBlanksFill') + exercise_qty_q.put(exercise_writeblanksfill_qty) + total_qty = total_qty + exercise_writeblanksfill_qty + if exercise_writeblanksform_qty != -1: + exercises.append('writeBlanksForm') + exercise_qty_q.put(exercise_writeblanksform_qty) + total_qty = total_qty + exercise_writeblanksform_qty + + response["exercises"]["exercise_" + str(i)] = gen_listening_section_1(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, + exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "listening" + + exercise_id = exercise_id + total_qty + elif exercise_type == CustomLevelExerciseTypes.LISTENING_2.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_mc_qty != -1: + exercises.append('multipleChoice') + exercise_qty_q.put(exercise_mc_qty) + total_qty = total_qty + exercise_mc_qty + if exercise_writeblanksquestions_qty != -1: + exercises.append('writeBlanksQuestions') + exercise_qty_q.put(exercise_writeblanksquestions_qty) + total_qty = total_qty + exercise_writeblanksquestions_qty + + response["exercises"]["exercise_" + str(i)] = gen_listening_section_2(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, + exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "listening" + + exercise_id = exercise_id + total_qty + elif exercise_type == CustomLevelExerciseTypes.LISTENING_3.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_mc3_qty != -1: + exercises.append('multipleChoice3Options') + exercise_qty_q.put(exercise_mc3_qty) + total_qty = total_qty + exercise_mc3_qty + if exercise_writeblanksquestions_qty != -1: + exercises.append('writeBlanksQuestions') + exercise_qty_q.put(exercise_writeblanksquestions_qty) + total_qty = total_qty + exercise_writeblanksquestions_qty + + response["exercises"]["exercise_" + str(i)] = gen_listening_section_3(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, + exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "listening" + + exercise_id = exercise_id + total_qty + elif exercise_type == CustomLevelExerciseTypes.LISTENING_4.value: + exercises = [] + exercise_qty_q = queue.Queue() + total_qty = 0 + if exercise_mc_qty != -1: + exercises.append('multipleChoice') + exercise_qty_q.put(exercise_mc_qty) + total_qty = total_qty + exercise_mc_qty + if exercise_writeblanksquestions_qty != -1: + exercises.append('writeBlanksQuestions') + exercise_qty_q.put(exercise_writeblanksquestions_qty) + total_qty = total_qty + exercise_writeblanksquestions_qty + if exercise_writeblanksfill_qty != -1: + exercises.append('writeBlanksFill') + exercise_qty_q.put(exercise_writeblanksfill_qty) + total_qty = total_qty + exercise_writeblanksfill_qty + if exercise_writeblanksform_qty != -1: + exercises.append('writeBlanksForm') + exercise_qty_q.put(exercise_writeblanksform_qty) + total_qty = total_qty + exercise_writeblanksform_qty + + response["exercises"]["exercise_" + str(i)] = gen_listening_section_4(exercise_topic, exercise_difficulty, + exercises, exercise_qty_q, + exercise_id) + response["exercises"]["exercise_" + str(i)]["type"] = "listening" + + exercise_id = exercise_id + total_qty return response + @app.route('/grade_short_answers', methods=['POST']) @jwt_required() def grade_short_answers(): @@ -1665,7 +1639,8 @@ def grade_short_answers(): }, { "role": "user", - "content": 'Grade these answers according to the text content and write a correct answer if they are wrong. Text, questions and answers:\n ' + str(data) + "content": 'Grade these answers according to the text content and write a correct answer if they are ' + 'wrong. Text, questions and answers:\n ' + str(data) } ] @@ -1675,6 +1650,7 @@ def grade_short_answers(): except Exception as e: return str(e) + @app.route('/fetch_tips', methods=['POST']) @jwt_required() def fetch_answer_tips(): diff --git a/helper/exercises.py b/helper/exercises.py index 53321c4..b3f22c5 100644 --- a/helper/exercises.py +++ b/helper/exercises.py @@ -15,19 +15,19 @@ from helper.speech_to_text_helper import has_x_words nltk.download('words') -def gen_reading_passage_1(topic, req_exercises, difficulty): +def gen_reading_passage_1(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=1): if (len(req_exercises) == 0): req_exercises = random.sample(READING_EXERCISE_TYPES, 2) - number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_1_EXERCISES, len(req_exercises)) + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_1_EXERCISES, len(req_exercises)) passage = generate_reading_passage_1_text(topic) if passage == "": - return gen_reading_passage_1(topic, req_exercises, difficulty) - start_id = 1 + return gen_reading_passage_1(topic, difficulty, req_exercises, number_of_exercises_q, start_id) exercises = generate_reading_exercises(passage["text"], req_exercises, number_of_exercises_q, start_id, difficulty) if contains_empty_dict(exercises): - return gen_reading_passage_1(topic, req_exercises, difficulty) + return gen_reading_passage_1(topic, difficulty, req_exercises, number_of_exercises_q, start_id) return { "exercises": exercises, "text": { @@ -38,19 +38,19 @@ def gen_reading_passage_1(topic, req_exercises, difficulty): } -def gen_reading_passage_2(topic, req_exercises, difficulty): +def gen_reading_passage_2(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=14): if (len(req_exercises) == 0): req_exercises = random.sample(READING_EXERCISE_TYPES, 2) - number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_2_EXERCISES, len(req_exercises)) + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_2_EXERCISES, len(req_exercises)) passage = generate_reading_passage_2_text(topic) if passage == "": - return gen_reading_passage_2(topic, req_exercises, difficulty) - start_id = 14 + return gen_reading_passage_2(topic, difficulty, req_exercises, number_of_exercises_q, start_id) exercises = generate_reading_exercises(passage["text"], req_exercises, number_of_exercises_q, start_id, difficulty) if contains_empty_dict(exercises): - return gen_reading_passage_2(topic, req_exercises, difficulty) + return gen_reading_passage_2(topic, difficulty, req_exercises, number_of_exercises_q, start_id) return { "exercises": exercises, "text": { @@ -61,19 +61,19 @@ def gen_reading_passage_2(topic, req_exercises, difficulty): } -def gen_reading_passage_3(topic, req_exercises, difficulty): +def gen_reading_passage_3(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=27): if (len(req_exercises) == 0): req_exercises = random.sample(READING_EXERCISE_TYPES, 2) - number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_3_EXERCISES, len(req_exercises)) + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_READING_PASSAGE_3_EXERCISES, len(req_exercises)) passage = generate_reading_passage_3_text(topic) if passage == "": - return gen_reading_passage_3(topic, req_exercises, difficulty) - start_id = 27 + return gen_reading_passage_3(topic, difficulty, req_exercises, number_of_exercises_q, start_id) exercises = generate_reading_exercises(passage["text"], req_exercises, number_of_exercises_q, start_id, difficulty) if contains_empty_dict(exercises): - return gen_reading_passage_3(topic, req_exercises, difficulty) + return gen_reading_passage_3(topic, difficulty, req_exercises, number_of_exercises_q, start_id) return { "exercises": exercises, "text": { @@ -865,7 +865,8 @@ def gen_idea_match_exercise(text: str, quantity: int, start_id): { "role": "user", "content": ( - 'From the text extract ' + str(quantity) + ' ideas, theories, opinions and who they are from. The text: ' + str(text)) + 'From the text extract ' + str( + quantity) + ' ideas, theories, opinions and who they are from. The text: ' + str(text)) } ] @@ -882,6 +883,7 @@ def gen_idea_match_exercise(text: str, quantity: int, start_id): "type": "matchSentences" } + def build_options(ideas): options = [] letters = iter(string.ascii_uppercase) @@ -892,6 +894,7 @@ def build_options(ideas): }) return options + def build_sentences(ideas, start_id): sentences = [] letters = iter(string.ascii_uppercase) @@ -906,6 +909,7 @@ def build_sentences(ideas, start_id): sentence["id"] = i return sentences + def assign_letters_to_paragraphs(paragraphs): result = [] letters = iter(string.ascii_uppercase) @@ -1272,7 +1276,8 @@ def replace_exercise_if_exists(all_exams, current_exercise, current_exam, seen_k current_exercise["options"]) for exercise in exercise_dict.get("exercises", [])[0]["questions"] ): - return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, seen_keys) + return replace_exercise_if_exists(all_exams, generate_single_mc_level_question(), current_exam, + seen_keys) return current_exercise, seen_keys @@ -1302,7 +1307,8 @@ def replace_blank_space_exercise_if_exists_utas(all_exams, current_exercise, cur key = (current_exercise['prompt'], tuple(sorted(option['text'] for option in current_exercise['options']))) # Check if the key is in the set if key in seen_keys: - return replace_exercise_if_exists_utas(all_exams, generate_single_mc_blank_space_level_question(), current_exam, seen_keys) + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_blank_space_level_question(), current_exam, + seen_keys) else: seen_keys.add(key) @@ -1313,7 +1319,8 @@ def replace_blank_space_exercise_if_exists_utas(all_exams, current_exercise, cur current_exercise["options"]) for exercise in exam.get("questions", []) ): - return replace_exercise_if_exists_utas(all_exams, generate_single_mc_blank_space_level_question(), current_exam, + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_blank_space_level_question(), + current_exam, seen_keys) return current_exercise, seen_keys @@ -1323,7 +1330,8 @@ def replace_underlined_exercise_if_exists_utas(all_exams, current_exercise, curr key = (current_exercise['prompt'], tuple(sorted(option['text'] for option in current_exercise['options']))) # Check if the key is in the set if key in seen_keys: - return replace_exercise_if_exists_utas(all_exams, generate_single_mc_underlined_level_question(), current_exam, seen_keys) + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_underlined_level_question(), current_exam, + seen_keys) else: seen_keys.add(key) @@ -1334,7 +1342,8 @@ def replace_underlined_exercise_if_exists_utas(all_exams, current_exercise, curr current_exercise["options"]) for exercise in exam.get("questions", []) ): - return replace_exercise_if_exists_utas(all_exams, generate_single_mc_underlined_level_question(), current_exam, + return replace_exercise_if_exists_utas(all_exams, generate_single_mc_underlined_level_question(), + current_exam, seen_keys) return current_exercise, seen_keys @@ -1376,8 +1385,8 @@ def generate_single_mc_blank_space_level_question(): }, { "role": "user", - "content": ('Generate 1 multiple choice blank space question of 4 options for an english level exam, it can be easy, ' - 'intermediate or advanced.') + "content": ('Generate 1 multiple choice blank space question of 4 options for an english level exam, ' + 'it can be easy, intermediate or advanced.') } ] @@ -1401,8 +1410,8 @@ def generate_single_mc_underlined_level_question(): }, { "role": "user", - "content": ('Generate 1 multiple choice blank space question of 4 options for an english level exam, it can be easy, ' - 'intermediate or advanced.') + "content": ('Generate 1 multiple choice blank space question of 4 options for an english level exam, ' + 'it can be easy, intermediate or advanced.') }, { @@ -1469,9 +1478,9 @@ def gen_multiple_choice_blank_space_utas(quantity: int, start_id: int, all_exams if all_exams is not None: seen_keys = set() for i in range(len(question["questions"])): - question["questions"][i], seen_keys = replace_blank_space_exercise_if_exists_utas(all_exams, question["questions"][i], - question, - seen_keys) + question["questions"][i], seen_keys = ( + replace_blank_space_exercise_if_exists_utas(all_exams, question["questions"][i], question, + seen_keys)) response = fix_exercise_ids(question, start_id) response["questions"] = randomize_mc_options_order(response["questions"]) return response @@ -1546,11 +1555,9 @@ def gen_multiple_choice_underlined_utas(quantity: int, start_id: int, all_exams= if all_exams is not None: seen_keys = set() for i in range(len(question["questions"])): - question["questions"][i], seen_keys = replace_underlined_exercise_if_exists_utas(all_exams, - question["questions"][ - i], - question, - seen_keys) + question["questions"][i], seen_keys = ( + replace_underlined_exercise_if_exists_utas(all_exams, question["questions"][i], question, + seen_keys)) response = fix_exercise_ids(question, start_id) response["questions"] = randomize_mc_options_order(response["questions"]) return response @@ -1765,7 +1772,8 @@ def generate_level_mc(start_id: int, quantity: int, all_questions=None): if all_questions is not None: seen_keys = set() for i in range(len(question["questions"])): - question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_questions, question["questions"][i], + question["questions"][i], seen_keys = replace_exercise_if_exists_utas(all_questions, + question["questions"][i], question, seen_keys) response = fix_exercise_ids(question, start_id) @@ -1791,3 +1799,293 @@ def randomize_mc_options_order(questions): question['solution'] = option['id'] return questions + + +def gen_writing_task_1(topic, difficulty): + messages = [ + { + "role": "system", + "content": ('You are a helpful assistant designed to output JSON on this format: ' + '{"prompt": "prompt content"}') + }, + { + "role": "user", + "content": ('Craft a prompt for an IELTS Writing Task 1 General Training exercise that instructs the ' + 'student to compose a letter. The prompt should present a specific scenario or situation, ' + 'based on the topic of "' + topic + '", requiring the student to provide information, ' + 'advice, or instructions within the letter. ' + 'Make sure that the generated prompt is ' + 'of ' + difficulty + 'difficulty and does not contain ' + 'forbidden subjects in muslim ' + 'countries.') + }, + { + "role": "user", + "content": 'The prompt should end with "In the letter you should" followed by 3 bullet points of what ' + 'the answer should include.' + } + ] + token_count = count_total_tokens(messages) + response = make_openai_call(GPT_3_5_TURBO, messages, token_count, "prompt", + GEN_QUESTION_TEMPERATURE) + return { + "question": add_newline_before_hyphen(response["prompt"].strip()), + "difficulty": difficulty, + "topic": topic + } + + +def add_newline_before_hyphen(s): + return s.replace(" -", "\n-") + + +def gen_writing_task_2(topic, difficulty): + messages = [ + { + "role": "system", + "content": ('You are a helpful assistant designed to output JSON on this format: ' + '{"prompt": "prompt content"}') + }, + { + "role": "user", + "content": ( + 'Craft a comprehensive question of ' + difficulty + 'difficulty like the ones for IELTS Writing ' + 'Task 2 General Training that directs the ' + 'candidate' + 'to delve into an in-depth analysis of ' + 'contrasting perspectives on the topic ' + 'of "' + topic + '". The candidate should be ' + 'asked to discuss the ' + 'strengths and weaknesses of ' + 'both viewpoints.') + }, + { + "role": "user", + "content": 'The question should lead to an answer with either "theories", "complicated information" or ' + 'be "very descriptive" on the topic.' + } + ] + token_count = count_total_tokens(messages) + response = make_openai_call(GPT_4_O, messages, token_count, "prompt", GEN_QUESTION_TEMPERATURE) + return { + "question": response["prompt"].strip(), + "difficulty": difficulty, + "topic": topic + } + + +def gen_speaking_part_1(first_topic: str, second_topic: str, difficulty): + json_format = { + "first_topic": "topic 1", + "second_topic": "topic 2", + "questions": [ + "Introductory question about the first topic, starting the topic with 'Let's talk about x' and then the " + "question.", + "Follow up question about the first topic", + "Follow up question about the first topic", + "Question about second topic", + "Follow up question about the second topic", + ] + } + + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + }, + { + "role": "user", + "content": ( + 'Craft 5 simple and single questions of easy difficulty for IELTS Speaking Part 1 ' + 'that encourages candidates to delve deeply into ' + 'personal experiences, preferences, or insights on the topic ' + 'of "' + first_topic + '" and the topic of "' + second_topic + '". ' + 'Make sure that the generated ' + 'question' + 'does not contain forbidden ' + 'subjects in' + 'muslim countries.') + }, + { + "role": "user", + "content": 'The questions should lead to the usage of 4 verb tenses (present perfect, present, ' + 'past and future).' + }, + { + "role": "user", + "content": 'They must be 1 single question each and not be double-barreled questions.' + + } + ] + token_count = count_total_tokens(messages) + response = make_openai_call(GPT_4_O, messages, token_count, ["first_topic"], + GEN_QUESTION_TEMPERATURE) + response["type"] = 1 + response["difficulty"] = difficulty + return response + + +def gen_speaking_part_2(topic: str, difficulty): + json_format = { + "topic": "topic", + "question": "question", + "prompts": [ + "prompt_1", + "prompt_2", + "prompt_3" + ], + "suffix": "And explain why..." + } + + messages = [ + { + "role": "system", + "content": 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format) + }, + { + "role": "user", + "content": ( + 'Create a question of medium difficulty for IELTS Speaking Part 2 ' + 'that encourages candidates to narrate a ' + 'personal experience or story related to the topic ' + 'of "' + topic + '". Include 3 prompts that ' + 'guide the candidate to describe ' + 'specific aspects of the experience, ' + 'such as details about the situation, ' + 'their actions, and the reasons it left a ' + 'lasting impression. Make sure that the ' + 'generated question does not contain ' + 'forbidden subjects in muslim countries.') + }, + { + "role": "user", + "content": 'The prompts must not be questions. Also include a suffix like the ones in the IELTS exams ' + 'that start with "And explain why".' + } + ] + token_count = count_total_tokens(messages) + response = make_openai_call(GPT_4_O, messages, token_count, GEN_FIELDS, GEN_QUESTION_TEMPERATURE) + response["type"] = 2 + response["difficulty"] = difficulty + response["topic"] = topic + return response + + +def gen_speaking_part_3(topic: str, difficulty): + json_format = { + "topic": "topic", + "questions": [ + "Introductory question about the topic.", + "Follow up question about the topic", + "Follow up question about the topic", + "Follow up question about the topic", + "Follow up question about the topic" + ] + } + + messages = [ + { + "role": "system", + "content": ( + 'You are a helpful assistant designed to output JSON on this format: ' + str(json_format)) + }, + { + "role": "user", + "content": ( + 'Formulate a set of 5 single questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a ' + 'meaningful discussion on the topic of "' + topic + '". Provide inquiries, ensuring ' + 'they explore various aspects, perspectives, and implications related to the topic.' + 'Make sure that the generated question does not contain forbidden subjects in muslim countries.') + + }, + { + "role": "user", + "content": 'They must be 1 single question each and not be double-barreled questions.' + + } + ] + token_count = count_total_tokens(messages) + response = make_openai_call(GPT_4_O, messages, token_count, GEN_FIELDS, GEN_QUESTION_TEMPERATURE) + # Remove the numbers from the questions only if the string starts with a number + response["questions"] = [re.sub(r"^\d+\.\s*", "", question) if re.match(r"^\d+\.", question) else question for + question in response["questions"]] + response["type"] = 3 + response["difficulty"] = difficulty + response["topic"] = topic + return response + + +def gen_listening_section_1(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=1): + if (len(req_exercises) == 0): + req_exercises = random.sample(LISTENING_1_EXERCISE_TYPES, 1) + + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_1_EXERCISES, len(req_exercises)) + + processed_conversation = generate_listening_1_conversation(topic) + + exercises = generate_listening_conversation_exercises(parse_conversation(processed_conversation), + req_exercises, + number_of_exercises_q, + start_id, difficulty) + return { + "exercises": exercises, + "text": processed_conversation, + "difficulty": difficulty + } + + +def gen_listening_section_2(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=11): + if (len(req_exercises) == 0): + req_exercises = random.sample(LISTENING_2_EXERCISE_TYPES, 2) + + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_2_EXERCISES, len(req_exercises)) + + monologue = generate_listening_2_monologue(topic) + + exercises = generate_listening_monologue_exercises(str(monologue), req_exercises, number_of_exercises_q, + start_id, difficulty) + return { + "exercises": exercises, + "text": monologue, + "difficulty": difficulty + } + + +def gen_listening_section_3(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=21): + if (len(req_exercises) == 0): + req_exercises = random.sample(LISTENING_3_EXERCISE_TYPES, 1) + + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_3_EXERCISES, len(req_exercises)) + + processed_conversation = generate_listening_3_conversation(topic) + + exercises = generate_listening_conversation_exercises(parse_conversation(processed_conversation), req_exercises, + number_of_exercises_q, + start_id, difficulty) + return { + "exercises": exercises, + "text": processed_conversation, + "difficulty": difficulty + } + + +def gen_listening_section_4(topic, difficulty, req_exercises, number_of_exercises_q=queue.Queue(), start_id=31): + if (len(req_exercises) == 0): + req_exercises = random.sample(LISTENING_EXERCISE_TYPES, 2) + + if (number_of_exercises_q.empty()): + number_of_exercises_q = divide_number_into_parts(TOTAL_LISTENING_SECTION_4_EXERCISES, len(req_exercises)) + + monologue = generate_listening_4_monologue(topic) + + exercises = generate_listening_monologue_exercises(str(monologue), req_exercises, number_of_exercises_q, + start_id, difficulty) + return { + "exercises": exercises, + "text": monologue, + "difficulty": difficulty + } From 03f5b7d72c9183cf4fa6d1e9ba35049ee45ed07b Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Sat, 17 Aug 2024 09:29:58 +0100 Subject: [PATCH 42/44] Upload level exam without hooking up to firestore and running in thread, will do this when I have the edit view done --- .idea/ielts-be.iml | 3 + Dockerfile | 66 ++- app.py | 20 +- modules/__init__.py | 5 + {training_content => modules}/gpt.py | 8 +- modules/helper/__init__.py | 5 + modules/helper/file_helper.py | 77 ++++ modules/helper/logger.py | 23 ++ .../training_content}/__init__.py | 4 +- .../training_content}/dtos.py | 0 .../training_content}/kb.py | 0 .../training_content}/service.py | 2 +- modules/upload_level/__init__.py | 5 + modules/upload_level/exam_dtos.py | 57 +++ modules/upload_level/mapper.py | 66 +++ modules/upload_level/service.py | 380 ++++++++++++++++++ modules/upload_level/sheet_dtos.py | 29 ++ requirements.txt | Bin 782 -> 876 bytes tmp/placeholder.txt | 1 + 19 files changed, 742 insertions(+), 9 deletions(-) create mode 100644 modules/__init__.py rename {training_content => modules}/gpt.py (92%) create mode 100644 modules/helper/__init__.py create mode 100644 modules/helper/file_helper.py create mode 100644 modules/helper/logger.py rename {training_content => modules/training_content}/__init__.py (66%) rename {training_content => modules/training_content}/dtos.py (100%) rename {training_content => modules/training_content}/kb.py (100%) rename {training_content => modules/training_content}/service.py (99%) create mode 100644 modules/upload_level/__init__.py create mode 100644 modules/upload_level/exam_dtos.py create mode 100644 modules/upload_level/mapper.py create mode 100644 modules/upload_level/service.py create mode 100644 modules/upload_level/sheet_dtos.py create mode 100644 tmp/placeholder.txt diff --git a/.idea/ielts-be.iml b/.idea/ielts-be.iml index 2b859b5..2cd02c1 100644 --- a/.idea/ielts-be.iml +++ b/.idea/ielts-be.iml @@ -7,6 +7,9 @@ + +

tags, try to figure ' + 'out the best paragraph separation possible.' + + 'You will place all the information in a single JSON: {"parts": [{"exercises": [{...}], "context": ""}]}\n ' + 'Where {...} are the exercises templates for each part of a question sheet and the optional field ' + 'context.' + + 'IMPORTANT: The question sheet may be divided by sections but you need to only consider the parts, ' + 'so that you can group the exercises by the parts that are in the html, this is crucial since only ' + 'reading passage multiple choice require context and if the context is included in parts where it ' + 'is not required the UI will be messed up. Some make sure to correctly group the exercises by parts.\n' + + 'The templates for the exercises are the following:\n' + '- blank space multiple choice, underline multiple choice and reading passage multiple choice: ' + f'{self._multiple_choice_html()}\n' + f'- reading passage blank space multiple choice: {self._passage_blank_space_html()}\n' + + 'IMPORTANT: For the reading passage multiple choice the context field must be set with the reading ' + 'passages without paragraphs or line numbers, with 2 newlines between paragraphs, for the other ' + 'exercises exclude the context field.' + ) + } + + @staticmethod + def _multiple_choice_html(): + return { + "type": "multipleChoice", + "prompt": "Select the appropriate option.", + "questions": [ + { + "id": "", + "prompt": "", + "solution": "", + "options": [ + { + "id": "A", + "text": "" + }, + { + "id": "B", + "text": "" + }, + { + "id": "C", + "text": "" + }, + { + "id": "D", + "text": "" + } + ] + } + ] + } + + @staticmethod + def _passage_blank_space_html(): + return { + "type": "fillBlanks", + "variant": "mc", + "prompt": "Click a blank to select the appropriate word for it.", + "text": ( + "}} with 2 newlines between paragraphs>" + ), + "solutions": [ + { + "id": "", + "solution": "" + } + ], + "words": [ + { + "id": "", + "options": { + "A": "", + "B": "", + "C": "", + "D": "" + } + } + ] + } + + def _png_completion(self, path_id: str) -> Exam: + FileHelper.pdf_to_png(path_id) + + tmp_files = os.listdir(f'./tmp/{path_id}') + pages = [f for f in tmp_files if f.startswith('page-') and f.endswith('.png')] + pages.sort(key=lambda f: int(f.split('-')[1].split('.')[0])) + + json_schema = { + "components": [ + {"type": "part", "part": ""}, + self._multiple_choice_png(), + {"type": "blanksPassage", "text": ( + "}} with 2 newlines between paragraphs>" + )}, + {"type": "passage", "context": ( + "" + )}, + self._passage_blank_space_png() + ] + } + + components = [] + + for i in range(len(pages)): + current_page = pages[i] + next_page = pages[i + 1] if i + 1 < len(pages) else None + batch = [current_page, next_page] if next_page else [current_page] + + sheet = self._png_batch(path_id, batch, json_schema) + sheet.batch = i + 1 + components.append(sheet.dict()) + + batches = {"batches": components} + with open('output.json', 'w') as json_file: + json.dump(batches, json_file, indent=4) + + return self._batches_to_exam_completion(batches) + + def _png_batch(self, path_id: str, files: list[str], json_schema) -> Sheet: + return self._llm.prediction( + [self._gpt_instructions_png(), + { + "role": "user", + "content": [ + *FileHelper.b64_pngs(path_id, files) + ] + } + ], + ExamMapper.map_to_sheet, + str(json_schema) + ) + + def _gpt_instructions_png(self): + return { + "role": "system", + "content": ( + 'You are GPT OCR and your job is to scan image text data and format it to JSON format.' + 'Your current task is to scan english questions sheets.\n\n' + + 'You will place all the information in a single JSON: {"components": [{...}]} where {...} is a set of ' + 'sheet components you will retrieve from the images, the components and their corresponding JSON ' + 'templates are as follows:\n' + + '- Part, a standalone part or part of a section of the question sheet: ' + '{"type": "part", "part": ""}\n' + + '- Multiple Choice Question, there are three types of multiple choice questions that differ on ' + 'the prompt field of the template: blanks, underlines and normal. ' + + 'In the blanks prompt you must leave 5 underscores to represent the blank space. ' + 'In the underlines questions the objective is to pick the words that are incorrect in the given ' + 'sentence, for these questions you must wrap the answer to the question with the html tag , ' + 'choose 3 other words to wrap in , place them in the prompt field and use the underlined words ' + 'in the order they appear in the question for the options A to D, disreguard options that might be ' + 'included underneath the underlines question and use the ones you wrapped in .' + 'In normal you just leave the question as is. ' + + f'The template for multiple choice questions is the following: {self._multiple_choice_png()}.\n' + + '- Reading Passages, there are two types of reading passages. Reading passages where you will see ' + 'blanks represented by a (question id) followed by a line, you must format these types of reading ' + 'passages to be only the text with the brackets that have the question id and line replaced with ' + '"{{question id}}", also place 2 newlines between paragraphs. For the reading passages without blanks ' + 'you must remove any numbers that may be there to specify paragraph numbers or line numbers, ' + 'and place 2 newlines between paragraphs. ' + + 'For the reading passages with blanks the template is: {"type": "blanksPassage", ' + '"text": "}} also place 2 newlines between paragraphs>"}. ' + + 'For the reading passage without blanks is: {"type": "passage", "context": ""}\n' + + '- Blanks Options, options for a blanks reading passage exercise, this type of component is a group of ' + 'options with the question id and the options from a to d. The template is: ' + f'{self._passage_blank_space_png()}\n' + + 'IMPORTANT: You must place the components in the order that they were given to you. If an exercise or ' + 'reading passages are cut off don\'t include them in the JSON.' + ) + } + + def _multiple_choice_png(self): + multiple_choice = self._multiple_choice_html()["questions"][0] + multiple_choice["type"] = "multipleChoice" + multiple_choice.pop("solution") + return multiple_choice + + def _passage_blank_space_png(self): + passage_blank_space = self._passage_blank_space_html()["words"][0] + passage_blank_space["type"] = "fillBlanks" + return passage_blank_space + + def _batches_to_exam_completion(self, batches: Dict[str, Any]) -> Exam: + return self._llm.prediction( + [self._gpt_instructions_html(), + { + "role": "user", + "content": str(batches) + } + ], + ExamMapper.map_to_exam_model, + str(self._level_json_schema()) + ) + + def _gpt_instructions_batches(self): + return { + "role": "system", + "content": ( + 'You are helpfull assistant. Your task is to merge multiple batches of english question sheet ' + 'components and solve the questions. Each batch may contain overlapping content with the previous ' + 'batch, or close enough content which needs to be excluded. The components are as follows:' + + '- Part, a standalone part or part of a section of the question sheet: ' + '{"type": "part", "part": ""}\n' + + '- Multiple Choice Question, there are three types of multiple choice questions that differ on ' + 'the prompt field of the template: blanks, underlines and normal. ' + + 'In a blanks question, the prompt has underscores to represent the blank space, you must select the ' + 'appropriate option to solve it.' + + 'In a underlines question, the prompt has 4 underlines represented by the html tags , you must ' + 'select the option that makes the prompt incorrect to solve it. If the options order doesn\'t reflect ' + 'the order in which the underlines appear in the prompt you will need to fix it.' + + 'In a normal question there isn\'t either blanks or underlines in the prompt, you should just ' + 'select the appropriate solution.' + + f'The template for these questions is the same: {self._multiple_choice_png()}\n' + + '- Reading Passages, there are two types of reading passages with different templates. The one with ' + 'type "blanksPassage" where the text field holds the passage and a blank is represented by ' + '{{}} and the other one with type "passage" that has the context field with just ' + 'reading passages. For both of these components you will have to remove any additional data that might ' + 'be related to a question description and also remove some "()" and "_" from blanksPassage' + ' if there are any. These components are used in conjunction with other ones.' + + '- Blanks Options, options for a blanks reading passage exercise, this type of component is a group of ' + 'options with the question id and the options from a to d. The template is: ' + f'{self._passage_blank_space_png()}\n\n' + + 'Now that you know the possible components here\'s what I want you to do:\n' + '1. Remove duplicates. A batch will have duplicates of other batches and the components of ' + 'the next batch should always take precedence over the previous one batch, what I mean by this is that ' + 'if batch 1 has, for example, multiple choice question with id 10 and the next one also has id 10, ' + 'you pick the next one.\n' + '2. Solve the exercises. There are 4 types of exercises, the 3 multipleChoice variants + a fill blanks ' + 'exercise. For the multiple choice question follow the previous instruction to solve them and place ' + f'them in this format: {self._multiple_choice_html()}. For the fill blanks exercises you need to match ' + 'the correct blanksPassage to the correct fillBlanks options and then pick the correct option. Here is ' + f'the template for this exercise: {self._passage_blank_space_html()}.\n' + f'3. Restructure the JSON to match this template: {self._level_json_schema()}. You must group the exercises by ' + 'the parts in the order they appear in the batches components. The context field of a part is the ' + 'context of a passage component that has text relevant to normal multiple choice questions.\n' + + 'Do your utmost to fullfill the requisites, make sure you include all non-duplicate questions' + 'in your response and correctly structure the JSON.' + ) + } + diff --git a/modules/upload_level/sheet_dtos.py b/modules/upload_level/sheet_dtos.py new file mode 100644 index 0000000..8efac82 --- /dev/null +++ b/modules/upload_level/sheet_dtos.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel +from typing import List, Dict, Union, Any, Optional + + +class Option(BaseModel): + id: str + text: str + + +class MultipleChoiceQuestion(BaseModel): + type: str = "multipleChoice" + id: str + prompt: str + variant: str = "text" + options: List[Option] + + +class FillBlanksWord(BaseModel): + type: str = "fillBlanks" + id: str + options: Dict[str, str] + + +Component = Union[MultipleChoiceQuestion, FillBlanksWord, Dict[str, Any]] + + +class Sheet(BaseModel): + batch: Optional[int] = None + components: List[Component] diff --git a/requirements.txt b/requirements.txt index 9a6e207d9458ee7430e4939b635764d3f48f4756..8afd38d986d5942f9272addb204ac39fadadcaf7 100644 GIT binary patch delta 102 zcmeBUd&9PYk2$V@p^~A1A(0`EA%!8IA(?@ffeXk_VMqg#ISi!?xeQ4RsSHI>@k*c! bNNov2CRA-6SPjS&h|Wx)t{k9A Date: Mon, 26 Aug 2024 20:14:22 +0100 Subject: [PATCH 43/44] Removed unused latext packages, texlive already includes the needed packages for level upload --- Dockerfile | 53 ----------------------------------------------------- 1 file changed, 53 deletions(-) diff --git a/Dockerfile b/Dockerfile index 482c98f..89d3dc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,59 +23,6 @@ RUN apt update && apt install -y \ librsvg2-bin \ && rm -rf /var/lib/apt/lists/* -# Install additional LaTeX packages -RUN tlmgr init-usertree && \ - tlmgr install \ - adjustbox \ - booktabs \ - caption \ - collectbox \ - enumitem \ - environ \ - eurosym \ - fancyhdr \ - float \ - ifoddpage \ - lastpage \ - listings \ - makecell \ - marginnote \ - microtype \ - multirow \ - needspace \ - parskip \ - pdfpages \ - sourcesanspro \ - tcolorbox \ - threeparttable \ - tikz \ - titlesec \ - tocbibind \ - tocloft \ - trimspaces \ - ulem \ - varwidth \ - wrapfig \ - babel \ - hyphenat \ - ifplatform \ - letltxmacro \ - lineno \ - marvosym \ - pgf \ - realscripts \ - soul \ - tabu \ - times \ - titling \ - ucharcat \ - unicode-math \ - upquote \ - was \ - xcolor \ - xecjk \ - xltxtra \ - zref # Install production dependencies. RUN pip install --no-cache-dir -r requirements.txt From 06a8384f42a1eb7111be6f2f9021b5b9343fdccc Mon Sep 17 00:00:00 2001 From: Carlos Mesquita Date: Mon, 26 Aug 2024 20:15:03 +0100 Subject: [PATCH 44/44] Forgot to remove comment, already tested it in a container --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 89d3dc5..5c9b4e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,6 @@ ENV APP_HOME /app WORKDIR $APP_HOME COPY . ./ -# TODO: Test if these latex packages are enough for pandoc RUN apt update && apt install -y \ ffmpeg \ poppler-utils \