Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
33b8189
add test endpoint
Shumer-1 Oct 16, 2025
328972f
added stub
Shumer-1 Nov 11, 2025
28220b6
added new endpoint and question collection
Shumer-1 Nov 22, 2025
2832d83
removed unused parts
Shumer-1 Nov 26, 2025
835b5ce
initial commit
kiyro7 Nov 26, 2025
39d54cf
first prototype
kiyro7 Nov 26, 2025
d813c88
added LLM questions marker
kiyro7 Nov 26, 2025
f6e00b6
fix avatar uploading and models
Shumer-1 Dec 10, 2025
8e42c8e
removed methodology
kiyro7 Dec 10, 2025
48ed43c
requirements.txt added versions
kiyro7 Dec 10, 2025
1bcf046
simplified docker
kiyro7 Dec 10, 2025
8a54af1
heuristic patterns update
kiyro7 Dec 10, 2025
d7a57d7
updated questions ranking and added examples
kiyro7 Dec 10, 2025
e20a3e0
docker-compose finally done
kiyro7 Dec 24, 2025
5694ae7
interactive mode
kiyro7 Dec 24, 2025
6ec4877
logging added
kiyro7 Dec 24, 2025
0b28da7
logging update
kiyro7 Dec 28, 2025
39f7626
docker fix (builds aprox 40 mins)
kiyro7 Jan 5, 2026
bee9a7a
fixed heuristic questions generation
kiyro7 Jan 9, 2026
fa52f19
added front and states
Shumer-1 Jan 10, 2026
a16784b
clearing
kiyro7 Jan 10, 2026
c2df6e4
created static folder
kiyro7 Jan 10, 2026
666535d
full logs refactor and translation to russian
kiyro7 Jan 10, 2026
e92b6ac
stashed new question generator code for future updates
kiyro7 Jan 14, 2026
e7e72da
docker update - llm & stuff and code separated
kiyro7 Jan 15, 2026
21b960b
global question generator refactor
kiyro7 Jan 15, 2026
a89eb4d
added instructions
kiyro7 Jan 15, 2026
72916c8
add getting questions from database
Shumer-1 Jan 18, 2026
dc1cbd4
docx_parser from document_insight_system prototype (works) & docker f…
kiyro7 Feb 4, 2026
9adab60
testing paragraphs max nesting (founded max depth - 1)
kiyro7 Feb 4, 2026
7db5faf
first prototype of chapters detection (works)
kiyro7 Feb 4, 2026
755e248
add saving answers
Shumer-1 Feb 8, 2026
898ee3e
fix lti
Shumer-1 Feb 8, 2026
3e7cb1c
generator refactor for usage of DocxUploader & docker llm-init servic…
kiyro7 Feb 20, 2026
2c595cb
reduced num_beams for model and shuffled chunks of text for questions…
kiyro7 Feb 27, 2026
8869223
Merge pull request #448 from OSLL/test_endpoint
kiyro7 Mar 8, 2026
3cd08d7
Merge branch 'master' into questions_generator
kiyro7 Mar 8, 2026
9c8122f
added celery
kiyro7 Mar 14, 2026
9cadc4d
compose fix
kiyro7 Mar 14, 2026
f3fc98e
add answer evaluation
Shumer-1 Mar 9, 2026
38e3128
add result page
Shumer-1 Mar 9, 2026
040c7f2
fix docker
Shumer-1 Mar 23, 2026
83c77f2
possible compose fix
kiyro7 Mar 25, 2026
0b9cfd2
possible compose fix #2
kiyro7 Mar 25, 2026
1fb009d
CELERY WORKS!!!
kiyro7 Mar 26, 2026
fe7c97c
add upload page
Shumer-1 Mar 29, 2026
b20e2ba
add celery task logic
Shumer-1 Mar 29, 2026
1c1da23
fix celery
Shumer-1 Mar 29, 2026
ab80707
add celery logic
Shumer-1 Mar 29, 2026
c9f5dbd
Merge pull request #450 from OSLL/test_endpoint
kiyro7 Mar 29, 2026
d1d807b
first fixes
kiyro7 Apr 14, 2026
7b68940
dockerfiles fixes
kiyro7 Apr 16, 2026
95c1c1b
requirements.txt fix
kiyro7 Apr 18, 2026
c8464d4
questions order shuffle
kiyro7 Apr 19, 2026
e302609
add cancel session logic
Shumer-1 Apr 15, 2026
db72c5e
fix auth logic and add show all interview
Shumer-1 Apr 19, 2026
7d89bf0
some fix saving
Shumer-1 Apr 19, 2026
e3b1742
refactor code
Shumer-1 Apr 19, 2026
db8302f
add vars to config
Shumer-1 Apr 19, 2026
9ba6b53
add response
Shumer-1 Apr 19, 2026
1251040
fix window logic
Shumer-1 Apr 19, 2026
d8658ee
use format file backend logic
Shumer-1 Apr 19, 2026
1f8bb1c
fixes (Docker, generator)
kiyro7 Apr 19, 2026
9961141
Merge remote-tracking branch 'origin/questions_generator' into questi…
kiyro7 Apr 19, 2026
fc4cb45
mongo version update
kiyro7 Apr 19, 2026
f3bde7b
Revert "use format file backend logic"
Shumer-1 Apr 20, 2026
99ba79a
Revert "fix window logic"
Shumer-1 Apr 20, 2026
47c74a1
fix format logic
Shumer-1 Apr 20, 2026
1e9560a
add document ready and update config
Shumer-1 Apr 20, 2026
501f750
fix window logic
Shumer-1 Apr 20, 2026
b619bcf
fix upload page
Shumer-1 Apr 20, 2026
05ba373
fix result page
Shumer-1 Apr 20, 2026
7aea665
add new mongo odm file
Shumer-1 Apr 20, 2026
8f36341
fix task service
Shumer-1 Apr 20, 2026
9abaa1a
remove unused config
Shumer-1 Apr 20, 2026
b973df1
move methods
Shumer-1 Apr 20, 2026
daae10b
Docker simplified & generator fixes
kiyro7 Apr 20, 2026
3c0cdcf
Merge remote-tracking branch 'origin/questions_generator' into questi…
kiyro7 Apr 20, 2026
376cef4
llm questions fix & DbManager fix
kiyro7 Apr 21, 2026
0358789
add some fix
Shumer-1 Apr 21, 2026
5372f0c
add new criteria
Shumer-1 Apr 21, 2026
d459e64
add time limit
Shumer-1 Apr 21, 2026
1774c05
some fix
Shumer-1 Apr 21, 2026
97f934f
add json
Shumer-1 Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion app/mongo_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,24 @@ class Logs(MongoModel):
lineno = fields.IntegerField()

class StorageMeta(MongoModel):
used_size = fields.IntegerField()
used_size = fields.IntegerField()


class Questions(MongoModel):
session_id = fields.CharField()
text = fields.CharField()
order = fields.IntegerField(blank=True, default=0)
created_at = fields.DateTimeField(default=datetime.now(timezone.utc))
attributes = fields.DictField(blank=True, default={})
generation_metadata = fields.DictField(blank=True, default={})

class InterviewAvatars(MongoModel):
session_id = fields.CharField()
file_id = fields.ObjectIdField()

created_at = fields.DateTimeField(default=datetime.now(timezone.utc))
last_update = fields.DateTimeField(default=datetime.now(timezone.utc))

def save(self):
self.last_update = datetime.now(timezone.utc)
return super().save()
98 changes: 97 additions & 1 deletion app/mongo_odm.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
RecognizedAudioToProcess,
RecognizedPresentationsToProcess, Sessions,
TaskAttempts, TaskAttemptsToPassBack, Tasks,
Trainings, TrainingsToProcess, StorageMeta)
Trainings, TrainingsToProcess, StorageMeta, InterviewAvatars, Questions)
from app.status import (AudioStatus, PassBackStatus, PresentationStatus,
TrainingStatus)
from app.utils import remove_blank_and_none
Expand Down Expand Up @@ -119,6 +119,28 @@ def recalculate_used_storage_data(self):
total_size += file_doc['length']
self.set_used_storage_size(total_size)
logger.info(f"Storage size recalculated: {total_size/BYTES_PER_MB:.2f} MB")

def delete_file(self, file_id):
try:
oid = ObjectId(file_id)
except InvalidId as e:
logger.warning('Invalid file_id = {}: {}.'.format(file_id, e))
return

db = _get_db()
file_doc = db.fs.files.find_one({'_id': oid})
if not file_doc:
logger.warning('No file doc for file_id = {}.'.format(file_id))
return

length = file_doc.get('length', 0)
try:
self.storage.delete(oid)
except (NoFile, ValidationError) as e:
logger.warning('Error deleting file_id = {}: {}.'.format(file_id, e))
return

self.update_storage_size(-length)


class TrainingsDBManager:
Expand Down Expand Up @@ -929,3 +951,77 @@ def get_criterion_pack_by_name(self, name):

def get_all_criterion_packs(self):
return CriterionPack.objects.all().order_by([("name", pymongo.ASCENDING)])

class QuestionsDBManager:

def add_question(self, session_id: str, text: str):
question = Questions(session_id=session_id, text=text)
return question.save()

def get_questions_by_session(self, session_id: str):
return Questions.objects.raw({"session_id": session_id})

def remove_question(self, question_id):
return Questions.objects.get({"_id": question_id}).delete()

def get_all(self):
return Questions.objects.all()

class InterviewAvatarsDBManager:
def __new__(cls):
if not hasattr(cls, 'init_done'):
cls.instance = super(InterviewAvatarsDBManager, cls).__new__(cls)
connect(Config.c.mongodb.url + Config.c.mongodb.database_name)
cls.init_done = True
return cls.instance

def get_by_session_id(self, session_id: str):
try:
return InterviewAvatars.objects.get({'session_id': session_id})
except InterviewAvatars.DoesNotExist:
return None

def add_or_update_avatar(self, session_id: str, file_obj, filename: str | None = None):
storage = DBManager()
if filename is None:
filename = str(uuid.uuid4())
avatar_file_id = storage.add_file(file_obj, filename)
avatar = self.get_by_session_id(session_id)
if avatar is None:
avatar = InterviewAvatars(session_id=session_id, file_id=avatar_file_id)
else:
try:
storage.delete_file(avatar.file_id)
except Exception:
logger.warning('Failed to delete old avatar file for session_id = {}.'.format(session_id))
avatar.file_id = avatar_file_id

saved = avatar.save()
logger.info('Avatar saved for session_id = {}, file_id = {}.'.format(session_id, avatar_file_id))
return saved

def get_avatar_record(self, session_id: str) -> Union[InterviewAvatars, None]:
return self.get_by_session_id(session_id)

def get_avatar_file(self, session_id: str):
avatar = self.get_by_session_id(session_id)
if avatar is None:
logger.info('No avatar for session_id = {}.'.format(session_id))
return None

storage = DBManager()
return storage.get_file(avatar.file_id)

def delete_avatar(self, session_id: str):
avatar = self.get_by_session_id(session_id)
if avatar is None:
return

storage = DBManager()
try:
storage.delete_file(avatar.file_id)
except Exception as e:
logger.warning('Error deleting avatar file for session_id = {}: {}.'.format(session_id, e))

avatar.delete()
logger.info('Avatar deleted for session_id = {}.'.format(session_id))
120 changes: 120 additions & 0 deletions app/routes/interview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from flask import Blueprint, render_template, session, request, Response

from app.root_logger import get_root_logger
from app.lti_session_passback.auth_checkers import check_auth
from app.mongo_odm import InterviewAvatarsDBManager, QuestionsDBManager

routes_interview = Blueprint('routes_interview', __name__)
logger = get_root_logger()


@routes_interview.route('/interview/', methods=['GET'])
def interview_page():
user_session = check_auth()
if not user_session:
return "User session not found", 404

session_id = session.get('session_id')
if not session_id:
return "Session id not found", 404

avatar_record = InterviewAvatarsDBManager().get_avatar_record(session_id)
has_avatar = avatar_record is not None
logger.info("session_id" + session_id)
questions = QuestionsDBManager().get_questions_by_session(session_id)
logger.info(f"Questions count: {len(list(questions))}")

return render_template(
'interview.html',
has_avatar=has_avatar,
session_id=session_id,
questions=list(questions)
), 200


def _partial_response_file(grid_out):
file_size = getattr(grid_out, 'length', None)
if file_size is None:
grid_out.seek(0, 2) # SEEK_END
file_size = grid_out.tell()
grid_out.seek(0)

content_type = getattr(grid_out, 'content_type', None) or 'video/mp4'

range_header = request.headers.get('Range', None)
if not range_header:
def full_stream():
chunk_size = 8192
grid_out.seek(0)
while True:
chunk = grid_out.read(chunk_size)
if not chunk:
break
yield chunk

headers = {
'Content-Length': str(file_size),
'Accept-Ranges': 'bytes',
'Content-Type': content_type,
}
return Response(full_stream(), status=200, headers=headers)

try:
byte_range = range_header.strip().split('=')[1]
start_str, end_str = byte_range.split('-')
except Exception:
return Response(status=416)

try:
start = int(start_str) if start_str else 0
except ValueError:
start = 0

try:
end = int(end_str) if end_str else file_size - 1
except ValueError:
end = file_size - 1

if end >= file_size:
end = file_size - 1
if start > end:
return Response(status=416)

length = end - start + 1

def stream_range():
chunk_size = 8192
grid_out.seek(start)
remaining = length
while remaining > 0:
size = chunk_size if remaining >= chunk_size else remaining
data = grid_out.read(size)
if not data:
break
remaining -= len(data)
yield data

headers = {
'Content-Range': f'bytes {start}-{end}/{file_size}',
'Accept-Ranges': 'bytes',
'Content-Length': str(length),
'Content-Type': content_type,
}
return Response(stream_range(), status=206, headers=headers)


@routes_interview.route('/avatar_video')
def avatar_video():
user_session = check_auth()
if not user_session:
return '', 404

session_id = session.get('session_id')
if not session_id:
return '', 404

grid_out = InterviewAvatarsDBManager().get_avatar_file(session_id)
if grid_out is None:
return '', 404

return _partial_response_file(grid_out)
66 changes: 53 additions & 13 deletions app/routes/lti.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,7 @@
logger = get_root_logger()



@routes_lti.route('/lti', methods=['POST'])
def lti():
"""
Route that is an entry point for LTI.

:return: Redirects to training_greeting page, or
an empty dictionary with 404 HTTP return code if access was denied.
"""
def _handle_lti_common():
params = request.form

consumer_key = params.get('oauth_consumer_key', '')
Expand All @@ -30,25 +22,29 @@ def lti():
headers=dict(request.headers),
data=params,
url=request.url,
secret=consumer_secret
secret=consumer_secret,
)
if not check_request(request_info):
return {}, 404
return None

full_name = utils.get_person_name(params)
username = utils.get_username(params)
custom_params = utils.get_custom_params(params)
task_id = custom_params.get('task_id', '')
task_description = custom_params.get('task_description', '')
attempt_count = int(custom_params.get('attempt_count', 1))
required_points = float(custom_params.get('required_points', 0))
criteria_pack_id = CriteriaPackFactory().get_criteria_pack(custom_params.get('criteria_pack_id', '')).name
criteria_pack_id = CriteriaPackFactory().get_criteria_pack(
custom_params.get('criteria_pack_id', '')
).name
presentation_id = custom_params.get('presentation_id')
feedback_evaluator_id = int(custom_params.get('feedback_evaluator_id', 1))
role = utils.get_role(params)
params_for_passback = utils.extract_passback_params(params)
pres_formats = list(set(custom_params.get('formats', '').split(',')) & ALLOWED_EXTENSIONS) or [DEFAULT_EXTENSION]

SessionsDBManager().add_session(username, consumer_key, task_id, params_for_passback, role, pres_formats)

session['session_id'] = username
session['task_id'] = task_id
session['consumer_key'] = consumer_key
Expand All @@ -62,6 +58,50 @@ def lti():
if not PresentationFilesDBManager().get_presentation_file(presentation_id):
presentation_id = None

TasksDBManager().add_task_if_absent(task_id, task_description, attempt_count, required_points, criteria_pack_id, presentation_id)
TasksDBManager().add_task_if_absent(
task_id,
task_description,
attempt_count,
required_points,
criteria_pack_id,
presentation_id,
)

return {
"username": username,
"task_id": task_id,
"full_name": full_name,
"criteria_pack_id": criteria_pack_id,
"feedback_evaluator_id": feedback_evaluator_id,
"presentation_id": presentation_id,
"attempt_count": attempt_count,
"required_points": required_points,
}


@routes_lti.route('/lti', methods=['POST'])
def lti():
"""
Route that is an entry point for LTI (тренировки).

:return: Redirects to training_greeting page, or
an empty dictionary with 404 HTTP return code if access was denied.
"""
ctx = _handle_lti_common()
if ctx is None:
return {}, 404

return redirect(url_for('routes_trainings.view_training_greeting'))


@routes_lti.route('/lti_interview', methods=['POST'])
def lti_interview():
"""
LTI entry point для интервью.
"""
ctx = _handle_lti_common()
if ctx is None:
return {}, 404

username = ctx["username"]
return redirect(url_for('routes_interview.interview_page'), )
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А что если не создавать новый роут, а (при необходимости) добавить параметры к старому?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Поддерживаю - достаточно добавить LTI-параметр для переключения на интервью, т.к. логика отличается только в том, куда перенаправить

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Согласен, поправил

Loading