Skip to content

Commit 5520637

Browse files
committed
Refactored services
1 parent 83110d1 commit 5520637

8 files changed

Lines changed: 346 additions & 0 deletions

File tree

app/services/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .test_services import (
2+
generate_test_service,
3+
evaluate_answers_service,
4+
calculate_percentage_score_service
5+
)
6+
7+
from .course_services import (
8+
generate_knowledge_assessment_service,
9+
create_course_service,
10+
generate_improved_course_name
11+
)
12+
13+
from .lesson_services import (
14+
generate_lesson_content_service
15+
)
16+
17+
from .tutor_services import get_tutor_response_service
18+
19+
from .edit_services import edit_course_service

app/services/course_services.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from app.ai_clients import ask_ai, ask_gemini
2+
from app.models import db, Course, Lesson
3+
from models.json_extractor import JsonExtractor
4+
from models.prompt_builders import CoursePromptBuilder
5+
from .utils import update_token_count
6+
from .test_services import calculate_percentage_score_service
7+
8+
def generate_knowledge_assessment_service(detailed_results):
9+
prompt = ("Provide a concise, one or two paragraph assessment of the user's knowledge "
10+
"of the topic based on the test results below. Do not address the user directly; "
11+
"use third-person phrasing. Do not use markdown.\n\n")
12+
for result in detailed_results:
13+
prompt += f"Q: {result['question']}\nA: {result['answer']}\nAssessment: {result['assessment']}\n\n"
14+
15+
assessment_text, tokens = ask_ai(prompt, model="gpt-4o-mini", json_mode=False)
16+
update_token_count(tokens)
17+
return assessment_text if assessment_text and "Error:" not in assessment_text else "Could not generate assessment."
18+
19+
20+
def create_course_service(user, topic, knowledge_assessment, assessed_answers):
21+
prompt = CoursePromptBuilder.build_course_structure_prompt(
22+
topic, knowledge_assessment, assessed_answers, user.language, user
23+
)
24+
raw_output, tokens = ask_ai(prompt, json_mode=True)
25+
update_token_count(tokens)
26+
27+
if not raw_output or "Error:" in raw_output:
28+
print(f"Failed to generate course structure. AI response: {raw_output}")
29+
return None
30+
31+
course_data = JsonExtractor.extract_json(raw_output)
32+
if not course_data:
33+
print("Failed to parse course structure from AI response.")
34+
return None
35+
36+
from .course_services import generate_improved_course_name
37+
original_name = course_data.get("course_title", topic)
38+
improved_name = generate_improved_course_name(original_name)
39+
course_data['course_title'] = improved_name
40+
41+
course = Course(user_id=user.id, course_title=improved_name, course_data=course_data)
42+
db.session.add(course)
43+
db.session.commit()
44+
45+
for unit in course_data.get('units', []):
46+
for lesson_data in unit.get('lessons', []):
47+
new_lesson = Lesson(course_id=course.id,
48+
unit_title=unit.get('unit_title'),
49+
lesson_title=lesson_data.get('lesson_title'))
50+
db.session.add(new_lesson)
51+
db.session.commit()
52+
return course
53+
54+
55+
def generate_improved_course_name(original_name):
56+
prompt = (f"Improve this course name to be more concise and informative. Keep it under 60 characters. "
57+
f"Original name: {original_name}. Return ONLY the improved name, no quotes or additional text.")
58+
improved_name, tokens = ask_gemini(prompt)
59+
if not improved_name or "Error:" in improved_name:
60+
improved_name, tokens = ask_ai(prompt)
61+
update_token_count(tokens)
62+
63+
improved_name = improved_name.strip('"\'').strip()
64+
return improved_name if improved_name else original_name

app/services/edit_services.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from app.models import db, Lesson
2+
from app.ai_clients import ask_ai
3+
from models.json_extractor import JsonExtractor
4+
from models.prompt_builders import CourseEditorPromptBuilder
5+
from .utils import update_token_count
6+
7+
def edit_course_service(course, user_request, language):
8+
print(f"[DEBUG] edit_course_service called with request: {user_request}")
9+
10+
title_keywords = ["title", "name", "rename", "call this"]
11+
is_title_update = any(k in user_request.lower() for k in title_keywords)
12+
13+
if is_title_update and "course_title" in course.course_data:
14+
print("[DEBUG] Processing title update request")
15+
prompt = CourseEditorPromptBuilder.build_title_improvement_prompt(user_request, language)
16+
new_title, tokens = ask_ai(prompt, model="gpt-4o-mini", json_mode=False)
17+
update_token_count(tokens)
18+
19+
if new_title and "Error:" not in new_title:
20+
new_title = new_title.strip('\'" ')
21+
course_data = course.course_data
22+
course_data["course_title"] = new_title
23+
course.course_data = course_data
24+
db.session.commit()
25+
return course_data, None
26+
else:
27+
err = f"Failed to generate an improved title. Response: {new_title}"
28+
print(f"[ERROR] {err}")
29+
return None, err
30+
31+
print("[DEBUG] Processing full course edit request")
32+
prompt = CourseEditorPromptBuilder.build_edit_prompt(course.course_data, user_request, language)
33+
new_course_str, tokens = ask_ai(prompt, model="gpt-4o", json_mode=True)
34+
update_token_count(tokens)
35+
36+
if not new_course_str or "Error:" in new_course_str:
37+
err = f"AI failed to generate a new course structure. Response: {new_course_str}"
38+
print(f"[ERROR] {err}")
39+
return None, err
40+
41+
try:
42+
new_course_json = JsonExtractor.extract_json(new_course_str)
43+
if 'course_title' not in new_course_json or 'units' not in new_course_json:
44+
raise ValueError(f"Invalid JSON structure returned by AI: {new_course_json}")
45+
except Exception as e:
46+
err = f"Error parsing new course structure: {str(e)}"
47+
print(f"[ERROR] {err}")
48+
return None, err
49+
50+
try:
51+
course.course_data = new_course_json
52+
new_titles = {l['lesson_title'] for u in new_course_json['units'] for l in u.get('lessons', []) if 'lesson_title' in l}
53+
54+
existing_lessons = {l.lesson_title: l for l in Lesson.query.filter_by(course_id=course.id).all()}
55+
for title, obj in existing_lessons.items():
56+
if title not in new_titles:
57+
db.session.delete(obj)
58+
59+
for u in new_course_json['units']:
60+
for l in u.get('lessons', []):
61+
t = l.get('lesson_title')
62+
if t and t not in existing_lessons:
63+
db.session.add(Lesson(course_id=course.id, unit_title=u['unit_title'], lesson_title=t))
64+
65+
course.completed_lessons = Lesson.query.filter_by(course_id=course.id, is_completed=True).count()
66+
db.session.commit()
67+
print("[DEBUG] Course update completed successfully")
68+
return new_course_json, None
69+
70+
except Exception as e:
71+
db.session.rollback()
72+
err = f"Database error during course update: {e}"
73+
print(f"[ERROR] {err}")
74+
import traceback
75+
traceback.print_exc()
76+
return None, err

app/services/lesson_services.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import re
2+
import markdown
3+
from flask import url_for
4+
from app.ai_clients import ask_ai_stream
5+
from app.models import db, Lesson
6+
from models.prompt_builders import LessonPromptBuilder
7+
8+
def generate_lesson_content_service(lesson, user):
9+
user_profile = {'age': user.age, 'bio': user.bio}
10+
course_structure = lesson.course.course_data
11+
12+
prompt = LessonPromptBuilder.build_lesson_content_prompt(
13+
lesson.lesson_title, lesson.unit_title, user.language, user.preferred_lesson_length,
14+
user_profile=user_profile, course_structure=course_structure
15+
)
16+
17+
def content_generator():
18+
response_stream = ask_ai_stream(prompt, model="gpt-4o")
19+
full_markdown_chunks = []
20+
21+
for chunk in response_stream:
22+
yield chunk
23+
full_markdown_chunks.append(chunk)
24+
25+
full_markdown_text = "".join(full_markdown_chunks)
26+
if "Error:" in full_markdown_text:
27+
lesson.html_content = "<p>Error generating lesson content. Please try again later.</p>"
28+
else:
29+
next_up_link_md = _generate_next_up_link(lesson, user)
30+
final_md = full_markdown_text + next_up_link_md
31+
processed_text = re.sub(r'\[IMAGE_PROMPT:\s*"(.*?)"\]', r'<i>[Image Prompt: "\1"]</i>', final_md)
32+
lesson.html_content = markdown.markdown(processed_text, extensions=["fenced_code", "tables"])
33+
34+
if not lesson.is_completed:
35+
lesson.is_completed = True
36+
course = lesson.course
37+
course.completed_lessons = Lesson.query.filter_by(course_id=course.id, is_completed=True).count()
38+
39+
db.session.commit()
40+
41+
return content_generator()
42+
43+
44+
def _generate_next_up_link(lesson, user):
45+
course = lesson.course
46+
lang = user.language
47+
try:
48+
units = course.course_data.get('units', [])
49+
current_unit_index, current_unit = next(
50+
((i, u) for i, u in enumerate(units) if u['unit_title'] == lesson.unit_title), (None, None))
51+
if not current_unit:
52+
return ""
53+
54+
lessons = current_unit.get('lessons', [])
55+
current_index = next(
56+
(i for i, l in enumerate(lessons) if l['lesson_title'] == lesson.lesson_title), None)
57+
if current_index is None:
58+
return ""
59+
60+
if current_index + 1 < len(lessons):
61+
next_lesson = lessons[current_index + 1]
62+
next_lesson_obj = Lesson.query.filter_by(course_id=course.id,
63+
lesson_title=next_lesson.get('lesson_title')).first()
64+
if next_lesson_obj:
65+
url = url_for('loading_lesson', lesson_id=next_lesson_obj.id, _external=True)
66+
return f"\n\n<hr>\n\n### 👉 {'Далее' if lang == 'russian' else 'Next up'}: [{next_lesson['lesson_title']}]({url})"
67+
68+
test_data = current_unit.get('test')
69+
if test_data and test_data.get('test_title'):
70+
url = url_for('loading_unit_test', course_id=course.id,
71+
unit_title=current_unit['unit_title'], test_title=test_data['test_title'], _external=True)
72+
return f"\n\n<hr>\n\n### 👉 {'Далее' if lang == 'russian' else 'Next up'}: [{test_data['test_title']}]({url})"
73+
except Exception as e:
74+
print(f"Error generating 'Next up' link: {e}")
75+
return ''

app/services/test_services.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import json
2+
from models.json_extractor import JsonExtractor
3+
from models.fulltest import Test
4+
from models.question import Question
5+
from app.ai_clients import ask_gemini
6+
from .utils import update_token_count
7+
from models.prompt_builders import TestPromptBuilder, AnswerPromptBuilder
8+
9+
def generate_test_service(topic, format_type, additional_context, language, user_profile=None, lesson_content_context=""):
10+
prompt = TestPromptBuilder.build_multiple_choice_prompt(
11+
topic, additional_context, language,
12+
user_profile=user_profile,
13+
lesson_content_context=lesson_content_context
14+
)
15+
raw_output, tokens = ask_gemini(prompt, json_mode=True)
16+
update_token_count(tokens)
17+
18+
if not raw_output or "Error:" in raw_output:
19+
print(f"Failed to generate test with Gemini. AI response: {raw_output}")
20+
return None
21+
22+
test_data = JsonExtractor.extract_json(raw_output)
23+
questions = [
24+
Question(question=q.get("question"), options=q.get("options", {}))
25+
for q in test_data.get("questions", [])
26+
]
27+
28+
return Test(
29+
test_name=test_data.get("test-name", "Unnamed Test"),
30+
topic=test_data.get("topic", topic),
31+
questions=questions
32+
)
33+
34+
35+
def evaluate_answers_service(questions, user_answers, language):
36+
qa_list = []
37+
for q, ua in zip(questions, user_answers):
38+
answer_key = ua.get('answer_value', ua.get('answer', ''))
39+
answer_text = ua.get('answer', '')
40+
if hasattr(q, 'options') and answer_key in q.options:
41+
answer_text = q.options[answer_key]
42+
qa_list.append({
43+
"question": q.question,
44+
"answer": answer_text,
45+
"original_answer": answer_key
46+
})
47+
48+
from app.ai_clients import ask_gemini
49+
prompt = AnswerPromptBuilder.build_batch_check_prompt(
50+
[{"question": qa["question"], "answer": qa["answer"]} for qa in qa_list],
51+
language
52+
)
53+
response_text, tokens = ask_gemini(prompt, json_mode=True)
54+
update_token_count(tokens)
55+
56+
if not response_text or "Error:" in response_text:
57+
return []
58+
59+
try:
60+
response_json = JsonExtractor.extract_json(response_text)
61+
assessments = response_json.get("assessments", [])
62+
detailed_results = []
63+
64+
for i, qa in enumerate(qa_list):
65+
assessment = next(
66+
(item['assessment'] for item in assessments if item['id'] == i),
67+
"Evaluation Error"
68+
)
69+
detailed_results.append({
70+
"question": qa["question"],
71+
"answer": qa["answer"],
72+
"original_answer": qa["original_answer"],
73+
"assessment": assessment
74+
})
75+
return detailed_results
76+
77+
except (ValueError, KeyError) as e:
78+
print(f"Error parsing Gemini batch assessment response: {e}")
79+
return []
80+
81+
82+
def calculate_percentage_score_service(detailed_results):
83+
from app.ai_clients import ask_gemini
84+
prompt = "Based on these answers and evaluations, give a final score from 0-100:\n\n"
85+
for result in detailed_results:
86+
prompt += f"Q: {result['question']}\nA: {result['answer']}\nAssessment: {result['assessment']}\n\n"
87+
prompt += "Return just the number."
88+
result_text, tokens = ask_gemini(prompt, json_mode=False)
89+
update_token_count(tokens)
90+
91+
return int(''.join(filter(str.isdigit, result_text))) if result_text and "Error:" not in result_text else 0

app/services/tutor_services.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from app.ai_clients import ask_gemini_stream
2+
from models.prompt_builders import ChatPromptBuilder
3+
4+
def get_tutor_response_service(lesson, chat_history, user_question, user):
5+
lesson_content = lesson.html_content or "Lesson content has not been generated yet."
6+
prompt = ChatPromptBuilder.build_tutor_prompt(
7+
lesson_content=lesson_content,
8+
unit_title=lesson.unit_title,
9+
chat_history=chat_history,
10+
user_question=user_question,
11+
language=user.language
12+
)
13+
return ask_gemini_stream(prompt)

app/services/utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from flask_login import current_user
2+
from app.models import db
3+
4+
def update_token_count(tokens_to_add):
5+
"""Helper function to add tokens to the current user's total."""
6+
if tokens_to_add > 0:
7+
current_user.tokens_used += tokens_to_add
8+
db.session.commit()

instance/quillio.db

124 KB
Binary file not shown.

0 commit comments

Comments
 (0)