From de4dd927ff5e5885f89fc241188389928fbe8f16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 00:21:44 +0000 Subject: [PATCH 01/30] Add score extraction from CI logs Implements configurable score extraction from GitHub Actions logs with support for multiple patterns and automatic decimal separator detection. Features: - Score extraction from CI logs using configurable regex patterns - Multiple pattern support (tried in order until match found) - Automatic decimal separator detection from Google Sheets locale - Score validation (all occurrences must match) - Format: v@10.5 or v@10,5 depending on locale - Combined format with penalty: v@10.5-3 or v@10,5-3 - Frontend display of extracted scores - Comprehensive test coverage Backend changes: - New module: grading/score.py with score extraction logic - Updated grading/grader.py with check_score() method - Updated grading/sheets_client.py with get_decimal_separator() - Updated main.py to integrate score processing - Updated grading/__init__.py exports Frontend changes: - Updated RegistrationForm to display score information Configuration: - Add 'score.patterns' list to lab config in YAML - Patterns are regex with first capturing group = score - Optional feature (backward compatible) Documentation: - Updated CLAUDE.md with score configuration examples - Added test suite in tests/test_score.py Example config: ```yaml labs: "1": score: patterns: - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' ``` --- CLAUDE.md | 15 ++ .../components/registration-form/index.jsx | 12 + grading/__init__.py | 14 ++ grading/grader.py | 160 ++++++++++++- grading/score.py | 219 ++++++++++++++++++ grading/sheets_client.py | 39 ++++ main.py | 45 +++- tests/test_score.py | 183 +++++++++++++++ 8 files changed, 671 insertions(+), 16 deletions(-) create mode 100644 grading/score.py create mode 100644 tests/test_score.py diff --git a/CLAUDE.md b/CLAUDE.md index ca4303c..e36ef0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,10 @@ LOG_LEVEL=INFO # Logging level (optional) - **Success**: `v` written to Google Sheets - **Failure**: `x` written to Google Sheets - **With penalty**: `v-{n}` where n = penalty points +- **With score**: `v@{score}` where score = points earned (e.g., `v@10.5` or `v@10,5`) +- **With score and penalty**: `v@{score}-{n}` (e.g., `v@10.5-3` or `v@10,5-3`) - **Protection**: Can only overwrite empty cells, `x`, or cells starting with `?` +- **Decimal separator**: Automatically detected from Google Sheets locale settings ### Lab Config Structure (course YAML) @@ -79,8 +82,20 @@ labs: - cpplint files: # Required files in repo - lab2.cpp + score: # Optional: Extract score from logs + patterns: # List of regex patterns (tried in order) + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # e.g., "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # e.g., "Score is 10.5" -> 10.5 + - 'Total:\s+(\d+(?:[.,]\d+)?)' # e.g., "Total: 10" -> 10 ``` +**Score extraction notes:** +- Patterns are regex with first capturing group = score value +- Accepts both `.` and `,` as decimal separator in logs +- Output format matches Google Sheets locale (e.g., `10.5` for en_US, `10,5` for ru_RU) +- If score patterns configured but not found in logs → error +- If score patterns not configured → no score extraction (backward compatible) + ## CI/CD - **Tests**: Run on every push via `.github/workflows/tests.yml` diff --git a/frontend/courses-front/src/components/registration-form/index.jsx b/frontend/courses-front/src/components/registration-form/index.jsx index da38849..c5e5037 100644 --- a/frontend/courses-front/src/components/registration-form/index.jsx +++ b/frontend/courses-front/src/components/registration-form/index.jsx @@ -52,6 +52,7 @@ const handleSubmit = async () => { result: gradeResponse.result, passed: gradeResponse.passed, checks: gradeResponse.checks, + score: gradeResponse.score, }); } else if (gradeResponse.status === "rejected") { setCheckResult({ @@ -60,6 +61,7 @@ const handleSubmit = async () => { currentGrade: gradeResponse.current_grade, passed: gradeResponse.passed, checks: gradeResponse.checks, + score: gradeResponse.score, }); } else if (gradeResponse.status === "pending") { setCheckResult({ @@ -176,6 +178,16 @@ const handleSubmit = async () => { )} + {checkResult.score && ( +
+ Баллы: {checkResult.score} +
+ )} + {checkResult.currentGrade && (
tuple[str | None, GradeResult | None]: + """ + Extract score from job logs using configured patterns. + + Reads logs from successful CI jobs and extracts score using pattern list. + If multiple occurrences found, they must all match (same value). + + Args: + org: GitHub organization + repo_name: Repository name + successful_runs: List of successful CheckRun objects + score_patterns: List of regex patterns to try + + Returns: + Tuple of (score_string, error_result) + - If successful: (score, None) + - If error: (None, GradeResult with error) + + Note: + Score patterns are tried in order. First matching pattern is used. + Score must be consistent across all successful jobs. + """ + logger.info(f"Score check for {repo_name}: checking {len(successful_runs)} successful job(s)") + logger.info(f"Score patterns: {score_patterns}") + + score_found = None + score_error = None + + # Try to get score from any successful job's logs + for run in successful_runs: + logger.info(f"Checking job: {run.name} (conclusion: {run.conclusion})") + + # Extract job ID from html_url (format: .../job/12345) + if "/job/" in run.html_url: + try: + job_id = int(run.html_url.split("/job/")[-1].split("?")[0]) + logger.info(f" Job ID: {job_id}, URL: {run.html_url}") + except (ValueError, IndexError): + logger.warning(f" Could not extract job_id from URL: {run.html_url}") + continue + + logs = self.github.get_job_logs(org, repo_name, job_id) + if logs: + logger.info(f" Logs fetched, size: {len(logs)} chars") + result = extract_score_from_logs(logs, score_patterns) + if result.found is not None: + logger.info(f" ✓ Score found in logs: {result.found}") + score_found = result.found + break + elif result.error: + if "несколько" in result.error: + # Multiple different scores - this is an error + logger.error(f" ✗ {result.error}") + score_error = result.error + break + else: + logger.info(f" ✗ Score not found in this job's logs: {result.error}") + else: + logger.warning(f" Could not fetch logs for job {job_id}") + else: + logger.warning(f" Job URL doesn't contain /job/: {run.html_url}") + + if score_error: + return None, GradeResult( + status=GradeStatus.ERROR, + result=None, + message=f"⚠️ {score_error}", + passed=None, + error_code="MULTIPLE_SCORES", + ) + + if score_found is None: + return None, GradeResult( + status=GradeStatus.ERROR, + result=None, + message="⚠️ Баллы не найдены в логах. Убедитесь, что программа выводит набранный балл.", + passed=None, + error_code="SCORE_NOT_FOUND", + ) + + logger.info(f"Score extracted successfully: {score_found}") + return score_found, None + def _evaluate_ci_internal( self, org: str, @@ -407,6 +499,26 @@ def _evaluate_ci_internal( else: message = "Результат CI: ❌ Обнаружены ошибки" + # Extract score if patterns are configured (only for passed CI) + score_value = None + if ci_result.passed: + score_patterns = lab_config.get("score", {}).get("patterns", []) + if score_patterns: + logger.info(f"Score patterns configured, attempting to extract score") + score_value, score_error = self.check_score( + org, repo_name, + successful_runs, + score_patterns, + ) + if score_error: + logger.warning(f"Score extraction failed: {score_error.message}") + # If score is required but not found, return error + return CIEvaluation( + grade_result=score_error, + ci_passed=False, + ) + logger.info(f"Score extracted: {score_value}") + return CIEvaluation( grade_result=GradeResult( status=GradeStatus.UPDATED, @@ -414,10 +526,12 @@ def _evaluate_ci_internal( message=message, passed=result_string, checks=ci_result.summary, + score=score_value, ), ci_passed=ci_result.passed, successful_runs=successful_runs, latest_success_time=ci_result.latest_success_time, + score=score_value, ) def evaluate_ci( @@ -448,6 +562,7 @@ def grade( current_cell_value: str | None = None, deadline: datetime | None = None, expected_taskid: int | None = None, + decimal_separator: str = '.', ) -> GradeResult: """ Perform full grading workflow. @@ -456,9 +571,11 @@ def grade( 1. Check repository (files, workflows, commits) 2. Check for forbidden modifications 3. Evaluate CI results - 4. Validate TASKID (if required) - 5. Calculate penalty (if deadline provided) - 6. Check if grade can be updated (cell protection) + 4. Extract score from logs (if configured) + 5. Validate TASKID (if required) + 6. Calculate penalty (if deadline provided) + 7. Format grade with score and penalty + 8. Check if grade can be updated (cell protection) Args: org: GitHub organization @@ -467,6 +584,7 @@ def grade( current_cell_value: Current value in grade cell (for protection check) deadline: Deadline datetime for penalty calculation (None = no penalty) expected_taskid: Expected TASKID for validation (None = skip validation) + decimal_separator: Decimal separator for score formatting ('.' or ',') Returns: GradeResult with final status and grade @@ -508,7 +626,6 @@ def grade( return taskid_error # Step 5: Calculate penalty (if deadline provided) - final_result = "v" # CI passed penalty = 0 penalty_max = lab_config.get("penalty-max", 0) @@ -528,10 +645,24 @@ def grade( ) if penalty > 0: - final_result = format_grade_with_penalty("v", penalty) - logger.info(f"Applied penalty {penalty} for late submission: {final_result}") + logger.info(f"Calculated penalty: {penalty}") + + # Step 6: Format grade with score and penalty + from .score import format_grade_with_score, format_score - # Step 6: Check cell protection (if current value provided) + score_value = ci_evaluation.score + final_result = "v" + + if score_value is not None: + # Format score with correct separator and add penalty if present + final_result = format_grade_with_score("v", score_value, penalty, decimal_separator) + logger.info(f"Formatted grade with score: {final_result}") + elif penalty > 0: + # No score, but penalty exists + final_result = format_grade_with_penalty("v", penalty) + logger.info(f"Formatted grade with penalty: {final_result}") + + # Step 7: Check cell protection (if current value provided) if current_cell_value is not None: if not can_overwrite_cell(current_cell_value): return GradeResult( @@ -541,11 +672,19 @@ def grade( passed=ci_evaluation.grade_result.passed, checks=ci_evaluation.grade_result.checks, current_grade=current_cell_value, + score=score_value, ) # Build final message + message_parts = [] + if score_value is not None: + formatted_score = format_score(score_value, decimal_separator) + message_parts.append(f"Баллы: {formatted_score}") if penalty > 0: - message = f"Результат CI: ✅ Все проверки пройдены (штраф: -{penalty})" + message_parts.append(f"штраф: -{penalty}") + + if message_parts: + message = f"Результат CI: ✅ Все проверки пройдены ({', '.join(message_parts)})" else: message = ci_evaluation.grade_result.message @@ -555,4 +694,5 @@ def grade( message=message, passed=ci_evaluation.grade_result.passed, checks=ci_evaluation.grade_result.checks, + score=score_value, ) diff --git a/grading/score.py b/grading/score.py new file mode 100644 index 0000000..c7544f6 --- /dev/null +++ b/grading/score.py @@ -0,0 +1,219 @@ +""" +Score extraction from GitHub Actions logs. + +This module contains functions for extracting student scores (points) +from GitHub Actions job logs using configurable regex patterns. +""" +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + + +@dataclass +class ScoreResult: + """Result of score extraction from logs.""" + found: str | None # Score as string (e.g., "10.5" or "10,5") + error: str | None = None + + +def normalize_score(score_str: str) -> str: + """ + Normalize score string to use consistent decimal separator. + + Accepts both comma and dot as decimal separator in input. + Returns normalized string with the original separator preserved. + + Args: + score_str: Score string (e.g., "10.5" or "10,5") + + Returns: + Normalized score string + + Examples: + >>> normalize_score("10.5") + '10.5' + >>> normalize_score("10,5") + '10,5' + >>> normalize_score("10") + '10' + """ + return score_str.strip() + + +def scores_equal(score1: str, score2: str) -> bool: + """ + Compare two score strings for equality. + + Treats "10.5" and "10,5" as equal values. + + Args: + score1: First score string + score2: Second score string + + Returns: + True if scores represent the same numeric value + + Examples: + >>> scores_equal("10.5", "10,5") + True + >>> scores_equal("10", "10.0") + True + >>> scores_equal("10.5", "10.6") + False + """ + try: + # Replace comma with dot for numeric comparison + val1 = Decimal(score1.replace(',', '.')) + val2 = Decimal(score2.replace(',', '.')) + return val1 == val2 + except (InvalidOperation, ValueError): + # Fallback to string comparison if not valid numbers + return score1 == score2 + + +def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: + """ + Extract score from GitHub Actions job logs using multiple patterns. + + Tries each pattern in order until a match is found. + If multiple occurrences are found, they must all be the same value. + + GitHub Actions logs have timestamps at the beginning of each line like: + "2024-01-15T10:30:00.000Z ##[notice]Points 10/10" + + Args: + logs: Full text of the job logs + patterns: List of regex patterns to try (first capturing group = score) + + Returns: + ScoreResult with found score or error message + + Examples: + >>> logs = "2024-01-15T10:30:00.000Z Points 10.5\\n" + >>> patterns = [r'Points\\s+([\\d.,]+)'] + >>> result = extract_score_from_logs(logs, patterns) + >>> result.found + '10.5' + """ + import logging + logger = logging.getLogger(__name__) + + if not logs: + return ScoreResult(found=None, error="Логи пусты") + + if not patterns: + return ScoreResult(found=None, error="Паттерны для поиска баллов не указаны") + + all_matches = [] + matched_pattern = None + + # Try each pattern until we find matches + for pattern in patterns: + try: + # Search across all lines + matches = re.findall(pattern, logs, re.MULTILINE | re.IGNORECASE) + + if matches: + logger.debug(f"Pattern '{pattern}' matched {len(matches)} time(s)") + all_matches = matches + matched_pattern = pattern + break + except re.error as e: + logger.warning(f"Invalid regex pattern '{pattern}': {e}") + continue + + if not all_matches: + logger.debug(f"No matches found for any of {len(patterns)} pattern(s)") + return ScoreResult( + found=None, + error="Баллы не найдены в логах. Убедитесь, что программа выводит набранный балл." + ) + + # Normalize all matches + normalized_matches = [normalize_score(m) for m in all_matches] + + # Check all matches are the same value (allow different separators) + unique_scores = [] + for score in normalized_matches: + # Check if this score is already in unique list (considering "10.5" == "10,5") + is_duplicate = any(scores_equal(score, existing) for existing in unique_scores) + if not is_duplicate: + unique_scores.append(score) + + if len(unique_scores) > 1: + logger.warning(f"Multiple different scores found: {unique_scores}") + return ScoreResult( + found=None, + error=f"Найдено несколько разных значений баллов в логах: {', '.join(unique_scores)}. Обратитесь к преподавателю." + ) + + found_score = normalized_matches[0] + logger.info(f"Score extracted from logs: {found_score} (pattern: {matched_pattern}, {len(all_matches)} occurrence(s))") + + return ScoreResult(found=found_score) + + +def format_score(score: str, separator: str = '.') -> str: + """ + Format score with the specified decimal separator. + + Args: + score: Score string (e.g., "10.5" or "10,5") + separator: Desired decimal separator ('.' or ',') + + Returns: + Formatted score string + + Examples: + >>> format_score("10.5", ",") + '10,5' + >>> format_score("10,5", ".") + '10.5' + >>> format_score("10", ",") + '10' + """ + if separator not in ('.', ','): + raise ValueError(f"Invalid separator: {separator}") + + # Normalize to dot first + normalized = score.replace(',', '.') + + # Convert to desired separator + if separator == ',': + return normalized.replace('.', ',') + return normalized + + +def format_grade_with_score( + base_grade: str, + score: str, + penalty: int = 0, + separator: str = '.' +) -> str: + """ + Format grade string with score and optional penalty. + + Format: base_grade@score or base_grade@score-penalty + + Args: + base_grade: Base grade symbol (e.g., "v" for success) + score: Score value (e.g., "10.5") + penalty: Number of penalty points (default: 0) + separator: Decimal separator for score ('.' or ',') + + Returns: + Grade string, e.g., "v@10.5" or "v@10,5-3" + + Examples: + >>> format_grade_with_score("v", "10.5", 0, ".") + 'v@10.5' + >>> format_grade_with_score("v", "10.5", 3, ",") + 'v@10,5-3' + >>> format_grade_with_score("v", "10", 0, ".") + 'v@10' + """ + formatted_score = format_score(score, separator) + + if penalty > 0: + return f"{base_grade}@{formatted_score}-{penalty}" + return f"{base_grade}@{formatted_score}" diff --git a/grading/sheets_client.py b/grading/sheets_client.py index 4fcddcd..b454fcd 100644 --- a/grading/sheets_client.py +++ b/grading/sheets_client.py @@ -335,3 +335,42 @@ def get_student_order( except Exception as e: logger.error(f"Error reading task ID: {e}") return None + + +def get_decimal_separator(spreadsheet) -> str: + """ + Get decimal separator used by the spreadsheet based on its locale. + + Args: + spreadsheet: gspread Spreadsheet object + + Returns: + Decimal separator: '.' or ',' + + Note: + - Locales like en_US, en_GB use '.' + - Locales like ru_RU, de_DE, fr_FR use ',' + - Defaults to '.' if locale cannot be determined + """ + try: + # Get spreadsheet metadata to access locale + metadata = spreadsheet.fetch_sheet_metadata() + locale = metadata.get('properties', {}).get('locale', 'en_US') + + logger.debug(f"Spreadsheet locale: {locale}") + + # Locales that use comma as decimal separator + comma_locales = { + 'ru_RU', 'ru', 'de_DE', 'de', 'fr_FR', 'fr', 'es_ES', 'es', + 'it_IT', 'it', 'pt_BR', 'pt', 'nl_NL', 'nl', 'pl_PL', 'pl', + 'cs_CZ', 'cs', 'sv_SE', 'sv', 'da_DK', 'da', 'fi_FI', 'fi', + 'no_NO', 'no', 'tr_TR', 'tr', 'el_GR', 'el', 'hu_HU', 'hu', + } + + separator = ',' if locale in comma_locales else '.' + logger.info(f"Using decimal separator '{separator}' for locale {locale}") + return separator + + except Exception as e: + logger.warning(f"Could not determine spreadsheet locale: {e}. Using default separator '.'") + return '.' diff --git a/main.py b/main.py index b42e326..e5be819 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,9 @@ get_deadline_from_sheet, get_student_order, calculate_expected_taskid, + get_decimal_separator, + format_grade_with_score, + format_score, ) # Configure logging to both file and console @@ -668,12 +671,17 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad sheets_client = gspread.authorize(creds) try: - sheet = sheets_client.open_by_key(spreadsheet_id).worksheet(group_id) + spreadsheet = sheets_client.open_by_key(spreadsheet_id) + sheet = spreadsheet.worksheet(group_id) logger.info(f"Successfully opened worksheet '{group_id}'") except Exception as e: logger.error(f"Failed to open worksheet '{group_id}': {str(e)}") raise HTTPException(status_code=404, detail="Группа не найдена в Google Таблице") + # Get decimal separator from spreadsheet locale + decimal_separator = get_decimal_separator(spreadsheet) + logger.info(f"Using decimal separator: '{decimal_separator}'") + # Find GitHub column and student row header_row = sheet.row_values(1) try: @@ -711,6 +719,7 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad # Determine final grade final_result = ci_evaluation.grade_result.result # "v" or "x" final_message = ci_evaluation.grade_result.message + score_value = ci_evaluation.score # Extracted score from logs (if any) # Additional checks only if CI passed if ci_evaluation.ci_passed: @@ -741,6 +750,7 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad # Get timezone from course config to apply to deadline from sheet timezone_str = course_info.get("timezone") deadline = get_deadline_from_sheet(sheet, lab_col, deadline_row=1, timezone_str=timezone_str) + penalty = 0 if deadline and ci_evaluation.latest_success_time: from grading.penalty import calculate_penalty, format_grade_with_penalty, PenaltyStrategy penalty_max = lab_config_dict.get("penalty-max", 0) @@ -758,14 +768,31 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad ) if penalty > 0: - final_result = format_grade_with_penalty("v", penalty) - final_message = f"Результат CI: ✅ Все проверки пройдены (штраф: -{penalty})" - logger.info(f"Applied penalty {penalty} for late submission: {final_result}") + logger.info(f"Calculated penalty: {penalty}") + + # Format final result with score and penalty + if score_value is not None: + # Format grade with score (and penalty if present) + final_result = format_grade_with_score("v", score_value, penalty, decimal_separator) + logger.info(f"Formatted grade with score: {final_result}") + + # Build message + formatted_score = format_score(score_value, decimal_separator) + if penalty > 0: + final_message = f"Результат CI: ✅ Все проверки пройдены (Баллы: {formatted_score}, штраф: -{penalty})" + else: + final_message = f"Результат CI: ✅ Все проверки пройдены (Баллы: {formatted_score})" + elif penalty > 0: + # No score, but penalty exists + from grading.penalty import format_grade_with_penalty + final_result = format_grade_with_penalty("v", penalty) + final_message = f"Результат CI: ✅ Все проверки пройдены (штраф: -{penalty})" + logger.info(f"Applied penalty {penalty} for late submission: {final_result}") # Check cell protection if not can_overwrite_cell(current_value): logger.warning(f"Update rejected: cell already contains '{current_value}'") - return { + response = { "status": "rejected", "result": current_value, "message": "⚠️ Работа уже была проверена ранее. Обратитесь к преподавателю для пересдачи.", @@ -773,19 +800,25 @@ def grade_lab(request: Request, course_id: str, group_id: str, lab_id: str, grad "checks": ci_evaluation.grade_result.checks, "current_grade": current_value } + if score_value is not None: + response["score"] = format_score(score_value, decimal_separator) + return response # Update Google Sheets with new grade logger.info(f"Updating cell at row {row_idx}, column {lab_col} with result '{final_result}'") sheet.update_cell(row_idx, lab_col, final_result) logger.info(f"Successfully updated grade for '{username}' in lab {lab_id}") - return { + response = { "status": "updated", "result": final_result, "message": final_message, "passed": ci_evaluation.grade_result.passed, "checks": ci_evaluation.grade_result.checks } + if score_value is not None: + response["score"] = format_score(score_value, decimal_separator) + return response except HTTPException: raise except Exception as e: diff --git a/tests/test_score.py b/tests/test_score.py new file mode 100644 index 0000000..4c5fcf9 --- /dev/null +++ b/tests/test_score.py @@ -0,0 +1,183 @@ +""" +Tests for score extraction functionality. +""" +import pytest +from grading.score import ( + extract_score_from_logs, + format_score, + format_grade_with_score, + scores_equal, +) + + +class TestExtractScoreFromLogs: + """Tests for extract_score_from_logs function.""" + + def test_extract_score_with_notice_pattern(self): + """Test extracting score from GitHub notice format.""" + logs = "2024-01-15T10:30:00.000Z ##[notice]Points 10/10\n" + patterns = [r'##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10" + assert result.error is None + + def test_extract_score_with_score_is_pattern(self): + """Test extracting score from 'Score is' format.""" + logs = "2024-01-15T10:30:00.000Z Score is 10.5\n" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10.5" + assert result.error is None + + def test_extract_score_with_comma_separator(self): + """Test extracting score with comma decimal separator.""" + logs = "2024-01-15T10:30:00.000Z Total: 10,5\n" + patterns = [r'Total:\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10,5" + assert result.error is None + + def test_extract_score_tries_multiple_patterns(self): + """Test that multiple patterns are tried in order.""" + logs = "2024-01-15T10:30:00.000Z Score is 8.5\n" + patterns = [ + r'Points\s+(\d+(?:[.,]\d+)?)', # Won't match + r'Score\s+is\s+(\d+(?:[.,]\d+)?)', # Will match + ] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "8.5" + assert result.error is None + + def test_extract_score_multiple_occurrences_same_value(self): + """Test multiple occurrences of same score value.""" + logs = """2024-01-15T10:30:00.000Z Score is 10.5 +2024-01-15T10:30:01.000Z Score is 10.5 +2024-01-15T10:30:02.000Z Score is 10.5""" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found == "10.5" + assert result.error is None + + def test_extract_score_multiple_different_values_error(self): + """Test error when multiple different scores found.""" + logs = """2024-01-15T10:30:00.000Z Score is 10.5 +2024-01-15T10:30:01.000Z Score is 8.0""" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found is None + assert "несколько разных" in result.error.lower() + + def test_extract_score_not_found(self): + """Test error when score not found in logs.""" + logs = "2024-01-15T10:30:00.000Z No score here\n" + patterns = [r'Score\s+is\s+(\d+(?:[.,]\d+)?)'] + + result = extract_score_from_logs(logs, patterns) + + assert result.found is None + assert "не найдены" in result.error.lower() + + def test_extract_score_empty_logs(self): + """Test error with empty logs.""" + result = extract_score_from_logs("", [r'Score\s+is\s+(\d+)']) + + assert result.found is None + assert "пусты" in result.error.lower() + + def test_extract_score_empty_patterns(self): + """Test error with empty patterns list.""" + logs = "2024-01-15T10:30:00.000Z Score is 10\n" + + result = extract_score_from_logs(logs, []) + + assert result.found is None + assert "не указаны" in result.error.lower() + + +class TestScoresEqual: + """Tests for scores_equal function.""" + + def test_scores_equal_same_format(self): + """Test comparing scores with same format.""" + assert scores_equal("10.5", "10.5") is True + assert scores_equal("10,5", "10,5") is True + + def test_scores_equal_different_separators(self): + """Test comparing scores with different separators.""" + assert scores_equal("10.5", "10,5") is True + assert scores_equal("10,5", "10.5") is True + + def test_scores_equal_with_trailing_zeros(self): + """Test comparing scores with trailing zeros.""" + assert scores_equal("10", "10.0") is True + assert scores_equal("10.0", "10") is True + + def test_scores_not_equal(self): + """Test scores that are not equal.""" + assert scores_equal("10.5", "10.6") is False + assert scores_equal("10", "11") is False + + +class TestFormatScore: + """Tests for format_score function.""" + + def test_format_score_to_dot(self): + """Test formatting score with dot separator.""" + assert format_score("10.5", ".") == "10.5" + assert format_score("10,5", ".") == "10.5" + + def test_format_score_to_comma(self): + """Test formatting score with comma separator.""" + assert format_score("10.5", ",") == "10,5" + assert format_score("10,5", ",") == "10,5" + + def test_format_score_integer(self): + """Test formatting integer score.""" + assert format_score("10", ".") == "10" + assert format_score("10", ",") == "10" + + def test_format_score_invalid_separator(self): + """Test error with invalid separator.""" + with pytest.raises(ValueError): + format_score("10.5", ";") + + +class TestFormatGradeWithScore: + """Tests for format_grade_with_score function.""" + + def test_format_grade_score_only(self): + """Test formatting grade with score only.""" + result = format_grade_with_score("v", "10.5", 0, ".") + assert result == "v@10.5" + + def test_format_grade_score_with_penalty(self): + """Test formatting grade with score and penalty.""" + result = format_grade_with_score("v", "10.5", 3, ".") + assert result == "v@10.5-3" + + def test_format_grade_integer_score(self): + """Test formatting grade with integer score.""" + result = format_grade_with_score("v", "10", 0, ".") + assert result == "v@10" + + def test_format_grade_with_comma_separator(self): + """Test formatting grade with comma separator.""" + result = format_grade_with_score("v", "10.5", 0, ",") + assert result == "v@10,5" + + def test_format_grade_score_and_penalty_comma(self): + """Test formatting grade with score, penalty and comma.""" + result = format_grade_with_score("v", "10,5", 2, ",") + assert result == "v@10,5-2" From ad7d23c3f10cea85ac88f3593b4566138fe76671 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 3 Dec 2025 11:41:23 +0300 Subject: [PATCH 02/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 99f8e56..98e3b90 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -35,6 +35,11 @@ course: short-name: ДЗ0 penalty-max: 5 ignore-task-id: True + score: + patterns: + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: @@ -57,6 +62,11 @@ course: # taskid-max: 25 ignore-task-id: True penalty-max: 6 + score: + patterns: + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: From 97dc8dd392b659dd91c5aa00064b491dc8ff5966 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 3 Dec 2025 11:51:24 +0300 Subject: [PATCH 03/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 98e3b90..a71cafb 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -35,11 +35,11 @@ course: short-name: ДЗ0 penalty-max: 5 ignore-task-id: True - score: - patterns: - - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 - - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 - - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 + # score: + # patterns: + # - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + # - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + # - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: @@ -64,6 +64,7 @@ course: penalty-max: 6 score: patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ\s+ОЦЕНКА\s+В\s+ЖУРНАЛ:\s+(\d+(?:[.,]\d+)?)' - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 From 7bad787341dd370deb247532c7211e11b422ea3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 09:00:33 +0000 Subject: [PATCH 04/30] Add comprehensive documentation for score extraction feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added detailed section on score extraction from CI logs - Included multiple pattern examples for different log formats - Documented flexible pattern syntax with .*? for robust matching - Added tips for creating reliable regex patterns - Explained decimal separator auto-detection - Updated process description with score extraction step - Provided frontend display examples Key improvements: - Flexible pattern: 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' - Handles variable whitespace and formatting in logs - Better guidance for YAML regex configuration --- docs/PROJECT_DESCRIPTION.md | 112 +++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/docs/PROJECT_DESCRIPTION.md b/docs/PROJECT_DESCRIPTION.md index 74de49b..3a6f66d 100644 --- a/docs/PROJECT_DESCRIPTION.md +++ b/docs/PROJECT_DESCRIPTION.md @@ -102,12 +102,18 @@ - Получает последний коммит - Валидирует, что студент не модифицировал файл с тестами (`test_main.py`, `tests/`) - Получает статусы всех CI проверок (workflows) + - **Извлекает баллы из логов** (если настроено в конфиге) 3. **Защита от перезаписи**: Результат записывается только если текущее значение ячейки: - Пустое - Содержит "x" (предыдущая неудачная проверка) - Начинается с "?" (пометка преподавателя) -4. Результат (`v` = успех, `x` = ошибка) записывается в Google Sheets -5. Frontend отображает детальный результат проверки (персистентно, не всплывающее окно) +4. Результат записывается в Google Sheets: + - `v` = успех без баллов + - `x` = ошибка + - `v@10.5` = успех с баллами (разделитель зависит от locale таблицы) + - `v-3` = успех со штрафом + - `v@10.5-3` = успех с баллами и штрафом +5. Frontend отображает детальный результат проверки с информацией о баллах (персистентно, не всплывающее окно) ## Структура проекта @@ -239,6 +245,108 @@ course: **Примечание:** Метаданные отображения (статус, приоритет, логотип) хранятся в `courses/index.yaml`, а не в файлах курсов. +### Извлечение баллов из логов CI + +Система поддерживает автоматическое извлечение баллов из логов GitHub Actions для записи в Google Sheets. Эта функция полностью опциональна и активируется добавлением секции `score` в конфигурацию лабораторной работы. + +#### Конфигурация + +```yaml +labs: + "1": + github-prefix: "task1" + short-name: "ЛР1" + score: # Опциональная секция + patterns: # Список regex паттернов (пробуются по порядку) + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" -> 100 + - 'ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # "ОЦЕНКА В ЖУРНАЛ: 10.0" -> 10.0 + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 +``` + +#### Правила работы + +1. **Множественные паттерны**: Паттерны пробуются по порядку. Используется первый найденный результат. + +2. **Первая capturing group**: Паттерн должен содержать захватывающую группу `(...)` — это значение будет извлечено как балл. + +3. **Разделители**: Система принимает оба разделителя (`10.5` и `10,5`) во входных данных. + +4. **Валидация**: Если балл найден в нескольких джобах, все значения должны совпадать. Иначе — ошибка. + +5. **Обязательность**: + - Если `score.patterns` **указан** → баллы **обязательны**. Если не найдены — ошибка. + - Если `score.patterns` **не указан** → баллы не ищутся (backward compatible). + +6. **Автоматический разделитель**: При записи в Google Sheets используется разделитель из locale таблицы: + - `ru_RU`, `de_DE`, `fr_FR` → запятая (`10,5`) + - `en_US`, `en_GB` → точка (`10.5`) + +#### Формат записи в таблицу + +| Ситуация | Формат записи | Пример | +|----------|--------------|---------| +| Только успех | `v` | `v` | +| Успех с баллами | `v@{score}` | `v@10.5` или `v@10,5` | +| Успех со штрафом | `v-{penalty}` | `v-3` | +| Успех с баллами и штрафом | `v@{score}-{penalty}` | `v@10.5-3` | +| Неудача | `x` | `x` | + +#### Примеры паттернов для разных форматов + +**Русский формат:** +```yaml +score: + patterns: + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' +``` + +**GitHub Actions notice:** +```yaml +score: + patterns: + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' +``` + +**Autograding format:** +```yaml +score: + patterns: + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' + - 'Total:\s+(\d+(?:[.,]\d+)?)' +``` + +#### Советы по созданию паттернов + +1. **Используйте одинарные кавычки** в YAML для regex (`'...'`), чтобы избежать двойного экранирования. + +2. **Гибкие паттерны**: Используйте `.*?` для пропуска переменного количества символов: + ```yaml + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' + ``` + +3. **Учитывайте пробелы**: После таймстемпа в логах могут быть дополнительные пробелы. Используйте `\s*` или `\s+`. + +4. **Тестируйте паттерны**: Используйте [regex101.com](https://regex101.com/) для проверки паттернов на реальных логах. + +#### Отображение на frontend + +При успешном извлечении баллов, информация отображается в результатах проверки: + +``` +✅ Результат CI: Все проверки пройдены (Баллы: 10.5) +Результат: v@10.5 +Баллы: 10.5 +``` + +#### Техническая реализация + +- **Модуль**: `grading/score.py` +- **Функция извлечения**: `extract_score_from_logs(logs, patterns)` +- **Определение разделителя**: `get_decimal_separator(spreadsheet)` +- **Форматирование**: `format_grade_with_score(base, score, penalty, separator)` + ## Интеграции ### GitHub API From 056f235fdedd155e4e3854d6089d8ba0d9573a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 09:01:24 +0000 Subject: [PATCH 05/30] Fix score extraction pattern for flexible whitespace matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated pattern to use .*? (non-greedy any character) for robust matching: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' This handles: - Variable whitespace after GitHub Actions timestamp - Any formatting between key words - Optional whitespace before the score value Added additional patterns for common formats: - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' for total score format --- courses/fundamental-statistics-2025.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index a71cafb..d8df88c 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -64,10 +64,11 @@ course: penalty-max: 6 score: patterns: - - 'ПРЕДВАРИТЕЛЬНАЯ\s+ОЦЕНКА\s+В\s+ЖУРНАЛ:\s+(\d+(?:[.,]\d+)?)' - - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 - - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 - - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" -> 100 + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: From f8036cf516a023ccf9af8b4b088316c77f86eced Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 09:17:37 +0000 Subject: [PATCH 06/30] Add comprehensive debug logging for score extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhanced debugging to diagnose score pattern matching issues: - Show first/last 500 chars of logs being searched - Log each pattern attempt with detailed results - Show sample lines containing keywords (ОЦЕНКА, ЖУРНАЛ) - Number patterns and jobs for easier tracking - Display matched values when pattern succeeds - Indicate when no keyword lines found in logs This will help identify: - If logs contain expected text - Encoding or formatting issues - Which pattern (if any) should match - Which jobs are being checked --- grading/grader.py | 8 +++++--- grading/score.py | 22 ++++++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/grading/grader.py b/grading/grader.py index 574bb38..202e1d7 100644 --- a/grading/grader.py +++ b/grading/grader.py @@ -325,14 +325,16 @@ def check_score( Score must be consistent across all successful jobs. """ logger.info(f"Score check for {repo_name}: checking {len(successful_runs)} successful job(s)") - logger.info(f"Score patterns: {score_patterns}") + logger.info(f"Score patterns configured: {len(score_patterns)} pattern(s)") + for idx, pattern in enumerate(score_patterns, 1): + logger.debug(f" Pattern {idx}: {repr(pattern)}") score_found = None score_error = None # Try to get score from any successful job's logs - for run in successful_runs: - logger.info(f"Checking job: {run.name} (conclusion: {run.conclusion})") + for idx, run in enumerate(successful_runs, 1): + logger.info(f"Checking job {idx}/{len(successful_runs)}: {run.name} (conclusion: {run.conclusion})") # Extract job ID from html_url (format: .../job/12345) if "/job/" in run.html_url: diff --git a/grading/score.py b/grading/score.py index c7544f6..84116d1 100644 --- a/grading/score.py +++ b/grading/score.py @@ -104,20 +104,38 @@ def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: if not patterns: return ScoreResult(found=None, error="Паттерны для поиска баллов не указаны") + # Debug: show sample of logs being searched + logger.debug(f"Searching for score in logs (size: {len(logs)} chars)") + logger.debug(f"First 500 chars of logs: {repr(logs[:500])}") + logger.debug(f"Last 500 chars of logs: {repr(logs[-500:])}") + all_matches = [] matched_pattern = None # Try each pattern until we find matches - for pattern in patterns: + for idx, pattern in enumerate(patterns, 1): + logger.debug(f"Trying pattern {idx}/{len(patterns)}: {repr(pattern)}") try: # Search across all lines matches = re.findall(pattern, logs, re.MULTILINE | re.IGNORECASE) if matches: - logger.debug(f"Pattern '{pattern}' matched {len(matches)} time(s)") + logger.info(f"✓ Pattern {idx} matched {len(matches)} time(s): {pattern}") + logger.debug(f"Matched values: {matches}") all_matches = matches matched_pattern = pattern break + else: + logger.debug(f"✗ Pattern {idx} had no matches") + # Debug: show lines containing keywords for troubleshooting + if 'ОЦЕНКА' in pattern or 'ЖУРНАЛ' in pattern or 'ПРЕДВАРИТЕЛЬНАЯ' in pattern: + lines_with_keyword = [line for line in logs.split('\n') if 'ОЦЕНКА' in line or 'ЖУРНАЛ' in line] + if lines_with_keyword: + logger.debug(f"Found {len(lines_with_keyword)} lines containing ОЦЕНКА or ЖУРНАЛ. First 3:") + for line in lines_with_keyword[:3]: + logger.debug(f" {repr(line[:200])}") # repr to show invisible chars + else: + logger.debug("No lines found containing ОЦЕНКА or ЖУРНАЛ keywords") except re.error as e: logger.warning(f"Invalid regex pattern '{pattern}': {e}") continue From 9303bc13405bb6bb9f12656c96405d24bee0cbfe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 09:39:39 +0000 Subject: [PATCH 07/30] Add diagnostic logging to detect missing Python script output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue appears to be that the Python script output (ИТОГОВЫЙ ОТЧЁТ) is not present in the logs fetched from GitHub API, even though it's visible in the GitHub UI. This commit adds diagnostics to: - Search for common report keywords (ИТОГОВЫЙ ОТЧЁТ, ИТОГО:, баллов, etc.) - Show context around any found keywords - Check for timestamps around the expected output time (05:22:34) - Warn if no report keywords found This will help confirm whether the issue is: 1. Logs incomplete/truncated by GitHub API 2. Output in a different job or step 3. Some other fetching issue --- grading/score.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/grading/score.py b/grading/score.py index 84116d1..5b7b789 100644 --- a/grading/score.py +++ b/grading/score.py @@ -109,6 +109,29 @@ def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: logger.debug(f"First 500 chars of logs: {repr(logs[:500])}") logger.debug(f"Last 500 chars of logs: {repr(logs[-500:])}") + # Debug: Check for common report keywords to diagnose missing output + report_keywords = ['ИТОГОВЫЙ ОТЧЁТ', 'ИТОГО:', 'баллов', 'Студент', 'РЕЗУЛЬТАТЫ ПО БЛОКАМ'] + found_any_keyword = False + for keyword in report_keywords: + if keyword in logs: + logger.debug(f"✓ Found keyword '{keyword}' in logs") + # Show context around first occurrence + idx = logs.find(keyword) + start = max(0, idx - 100) + end = min(len(logs), idx + 200) + logger.debug(f" Context: {repr(logs[start:end])}") + found_any_keyword = True + break + + if not found_any_keyword: + logger.warning("⚠️ None of the report keywords found in logs. The Python script output may not be included in the fetched logs.") + logger.debug(f"Searched for keywords: {report_keywords}") + # Check if logs contain timestamps around expected time (05:22:34) + if '05:22:34' in logs or '05:22:33' in logs or '05:22:35' in logs: + logger.debug("✓ Found timestamps around 05:22:34 in logs") + else: + logger.debug("✗ No timestamps around 05:22:34 found (expected time for score output)") + all_matches = [] matched_pattern = None From 170d59ab22ec82403eecee7ebe1176f4da9a6b91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 09:49:58 +0000 Subject: [PATCH 08/30] Fix score extraction pattern matching issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Was only showing first/last 500 chars of logs in debug output, while logs contain 87K+ chars. The Python script output is in the middle. Changes: - Show middle 500 chars sample (around position len/2) - Add case-insensitive keyword search (both exact and lowercase) - Search for timestamp lines around 05:22:34 (expected score time) - Show sample lines from 25%, 50%, 75% positions in logs - Add ОЦЕНКА, ЖУРНАЛ, ПРЕДВАРИТЕЛЬНАЯ to keyword search list - Show position and context when keyword found This will reveal exactly where the score output is in the logs and why the pattern isn't matching it. --- grading/score.py | 49 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/grading/score.py b/grading/score.py index 5b7b789..32246fb 100644 --- a/grading/score.py +++ b/grading/score.py @@ -105,32 +105,61 @@ def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: return ScoreResult(found=None, error="Паттерны для поиска баллов не указаны") # Debug: show sample of logs being searched - logger.debug(f"Searching for score in logs (size: {len(logs)} chars)") - logger.debug(f"First 500 chars of logs: {repr(logs[:500])}") - logger.debug(f"Last 500 chars of logs: {repr(logs[-500:])}") + logger.debug(f"Searching for score in logs (size: {len(logs)} chars, {len(logs.splitlines())} lines)") + logger.debug(f"First 500 chars: {repr(logs[:500])}") + # Show middle sample + mid_point = len(logs) // 2 + logger.debug(f"Middle 500 chars (around pos {mid_point}): {repr(logs[mid_point-250:mid_point+250])}") + logger.debug(f"Last 500 chars: {repr(logs[-500:])}") # Debug: Check for common report keywords to diagnose missing output - report_keywords = ['ИТОГОВЫЙ ОТЧЁТ', 'ИТОГО:', 'баллов', 'Студент', 'РЕЗУЛЬТАТЫ ПО БЛОКАМ'] + # Try both case-sensitive and case-insensitive + report_keywords = ['ИТОГОВЫЙ ОТЧЁТ', 'ИТОГО:', 'баллов', 'Студент', 'РЕЗУЛЬТАТЫ ПО БЛОКАМ', + 'ОЦЕНКА', 'ЖУРНАЛ', 'ПРЕДВАРИТЕЛЬНАЯ'] found_any_keyword = False + for keyword in report_keywords: + # Try exact match first if keyword in logs: - logger.debug(f"✓ Found keyword '{keyword}' in logs") - # Show context around first occurrence + logger.debug(f"✓ Found keyword '{keyword}' in logs (case-sensitive)") idx = logs.find(keyword) start = max(0, idx - 100) end = min(len(logs), idx + 200) - logger.debug(f" Context: {repr(logs[start:end])}") + logger.debug(f" Position: {idx}, Context: {repr(logs[start:end])}") + found_any_keyword = True + break + # Try lowercase + elif keyword.lower() in logs.lower(): + logger.debug(f"✓ Found keyword '{keyword}' in logs (case-insensitive)") + logs_lower = logs.lower() + idx = logs_lower.find(keyword.lower()) + start = max(0, idx - 100) + end = min(len(logs), idx + 200) + logger.debug(f" Position: {idx}, Context: {repr(logs[start:end])}") found_any_keyword = True break if not found_any_keyword: - logger.warning("⚠️ None of the report keywords found in logs. The Python script output may not be included in the fetched logs.") + logger.warning("⚠️ None of the report keywords found in logs.") logger.debug(f"Searched for keywords: {report_keywords}") - # Check if logs contain timestamps around expected time (05:22:34) + # Show more samples to help diagnose + logger.debug("Sample lines from different parts of logs:") + lines = logs.splitlines() + if len(lines) > 100: + # Show lines from 25%, 50%, 75% positions + for pct in [25, 50, 75]: + idx = len(lines) * pct // 100 + logger.debug(f" Line {idx} ({pct}%): {repr(lines[idx][:150])}") + # Check timestamp around expected score time if '05:22:34' in logs or '05:22:33' in logs or '05:22:35' in logs: logger.debug("✓ Found timestamps around 05:22:34 in logs") + # Show lines with this timestamp + matching_lines = [line for line in logs.splitlines() if '05:22:34' in line or '05:22:33' in line or '05:22:35' in line] + logger.debug(f"Found {len(matching_lines)} lines with timestamps 05:22:33-35. First 5:") + for line in matching_lines[:5]: + logger.debug(f" {repr(line[:200])}") else: - logger.debug("✗ No timestamps around 05:22:34 found (expected time for score output)") + logger.debug("✗ No timestamps around 05:22:34 found") all_matches = [] matched_pattern = None From 2b60184bb544b70ca7f9b92d0c12cc9739c93887 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 09:54:00 +0000 Subject: [PATCH 09/30] Add detailed encoding and pattern matching diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added byte-level debugging to diagnose score pattern matching: - Test simple string search for 'ПРЕДВАРИТЕЛЬНАЯ' to verify Cyrillic works - Show UTF-8 bytes of both pattern and actual log line - Show number of matches found by each pattern attempt - Display sample line containing the target text with its bytes This will reveal if the issue is: - Encoding mismatch between pattern and logs - Pattern syntax error - Invisible characters in logs --- grading/score.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/grading/score.py b/grading/score.py index 32246fb..13a5b2a 100644 --- a/grading/score.py +++ b/grading/score.py @@ -164,12 +164,30 @@ def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: all_matches = [] matched_pattern = None + # Debug: Test simple Cyrillic search to verify encoding works + if 'ПРЕДВАРИТЕЛЬНАЯ' in logs: + logger.debug("✓ Simple string search for 'ПРЕДВАРИТЕЛЬНАЯ' works") + # Show the actual line containing it + for line in logs.splitlines(): + if 'ПРЕДВАРИТЕЛЬНАЯ' in line: + logger.debug(f" Found in line: {repr(line[:150])}") + # Show bytes of this part of line + idx_start = line.find('ПРЕДВАРИТЕЛЬНАЯ') + sample = line[idx_start:idx_start+60] + logger.debug(f" Sample bytes: {sample.encode('utf-8')}") + break + else: + logger.warning("✗ Simple string search for 'ПРЕДВАРИТЕЛЬНАЯ' failed - encoding issue?") + # Try each pattern until we find matches for idx, pattern in enumerate(patterns, 1): logger.debug(f"Trying pattern {idx}/{len(patterns)}: {repr(pattern)}") + # Debug: show pattern bytes to check encoding + logger.debug(f" Pattern bytes (first 100): {pattern.encode('utf-8')[:100]}") try: # Search across all lines matches = re.findall(pattern, logs, re.MULTILINE | re.IGNORECASE) + logger.debug(f" Found {len(matches)} matches") if matches: logger.info(f"✓ Pattern {idx} matched {len(matches)} time(s): {pattern}") From 5f8c1d6c1fa09a41c3961afd235e5985380d1d9f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 10:00:01 +0000 Subject: [PATCH 10/30] Fix score extraction pattern matching issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: GitHub API returns job logs in UTF-8, but without proper charset in Content-Type headers. The requests library was auto-detecting encoding incorrectly (likely as Latin-1/ISO-8859-1), causing Cyrillic characters to be mojibake (e.g., 'ПРЕДВАРИТЕЛЬНАЯ' became 'Ð\x9fÑ\x80ед...') Solution: Explicitly set resp.encoding = 'utf-8' before accessing resp.text to force proper UTF-8 decoding of GitHub Actions logs. This fixes: - Pattern matching for Cyrillic text in score extraction - TASKID extraction with Cyrillic output - Any other log parsing with non-ASCII characters Evidence from debug logs: - Simple string search for 'ПРЕДВАРИТЕЛЬНАЯ' was failing - Line 731 showed mojibake: 'ЦенÑ\x82Ñ\x80...' instead of Cyrillic - Pattern bytes were correct UTF-8 (b'\xd0\x9f\xd0\xa0...') - But log text was decoded wrong --- grading/github_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/grading/github_client.py b/grading/github_client.py index 090cd1e..e668661 100644 --- a/grading/github_client.py +++ b/grading/github_client.py @@ -194,6 +194,9 @@ def get_job_logs(self, org: str, repo: str, job_id: int) -> str | None: if resp.status_code != 200: return None + # GitHub API returns logs in UTF-8, but without proper charset in headers. + # Force UTF-8 decoding to correctly handle Cyrillic and other non-ASCII characters. + resp.encoding = 'utf-8' return resp.text From 07e74ed48800dbc49f6703eebde95d7a8604c862 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 3 Dec 2025 13:21:15 +0300 Subject: [PATCH 11/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index d8df88c..0ae2c9e 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -59,16 +59,16 @@ course: "1": github-prefix: fs-lab1 short-name: ДЗ1 - # taskid-max: 25 - ignore-task-id: True - penalty-max: 6 + taskid-max: 10 + # ignore-task-id: True + # penalty-max: 6 score: patterns: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" - - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" -> 100 - - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 - - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 - - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 + # - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" -> 100 + # - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" -> 10 + # - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" -> 10.5 + # - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" -> 10 ci: - workflows files: From ed5e2bc529cff5b54d3c1adab3fb2da2b7418dcc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 10:26:07 +0000 Subject: [PATCH 12/30] Remove excessive debug logging from score extraction Cleaned up verbose debug output now that encoding issue is resolved: - Removed log samples (first/middle/last 500 chars) - Removed keyword search diagnostics - Removed byte-level encoding checks - Removed pattern bytes display - Kept essential logging: log size, pattern attempts, match results The encoding fix (UTF-8) resolved the core issue, so detailed diagnostics are no longer needed for normal operation. --- grading/grader.py | 4 +-- grading/score.py | 89 ++--------------------------------------------- 2 files changed, 3 insertions(+), 90 deletions(-) diff --git a/grading/grader.py b/grading/grader.py index 202e1d7..85a4a31 100644 --- a/grading/grader.py +++ b/grading/grader.py @@ -325,9 +325,7 @@ def check_score( Score must be consistent across all successful jobs. """ logger.info(f"Score check for {repo_name}: checking {len(successful_runs)} successful job(s)") - logger.info(f"Score patterns configured: {len(score_patterns)} pattern(s)") - for idx, pattern in enumerate(score_patterns, 1): - logger.debug(f" Pattern {idx}: {repr(pattern)}") + logger.debug(f"Score patterns configured: {len(score_patterns)} pattern(s)") score_found = None score_error = None diff --git a/grading/score.py b/grading/score.py index 13a5b2a..8569590 100644 --- a/grading/score.py +++ b/grading/score.py @@ -104,108 +104,23 @@ def extract_score_from_logs(logs: str, patterns: list[str]) -> ScoreResult: if not patterns: return ScoreResult(found=None, error="Паттерны для поиска баллов не указаны") - # Debug: show sample of logs being searched logger.debug(f"Searching for score in logs (size: {len(logs)} chars, {len(logs.splitlines())} lines)") - logger.debug(f"First 500 chars: {repr(logs[:500])}") - # Show middle sample - mid_point = len(logs) // 2 - logger.debug(f"Middle 500 chars (around pos {mid_point}): {repr(logs[mid_point-250:mid_point+250])}") - logger.debug(f"Last 500 chars: {repr(logs[-500:])}") - - # Debug: Check for common report keywords to diagnose missing output - # Try both case-sensitive and case-insensitive - report_keywords = ['ИТОГОВЫЙ ОТЧЁТ', 'ИТОГО:', 'баллов', 'Студент', 'РЕЗУЛЬТАТЫ ПО БЛОКАМ', - 'ОЦЕНКА', 'ЖУРНАЛ', 'ПРЕДВАРИТЕЛЬНАЯ'] - found_any_keyword = False - - for keyword in report_keywords: - # Try exact match first - if keyword in logs: - logger.debug(f"✓ Found keyword '{keyword}' in logs (case-sensitive)") - idx = logs.find(keyword) - start = max(0, idx - 100) - end = min(len(logs), idx + 200) - logger.debug(f" Position: {idx}, Context: {repr(logs[start:end])}") - found_any_keyword = True - break - # Try lowercase - elif keyword.lower() in logs.lower(): - logger.debug(f"✓ Found keyword '{keyword}' in logs (case-insensitive)") - logs_lower = logs.lower() - idx = logs_lower.find(keyword.lower()) - start = max(0, idx - 100) - end = min(len(logs), idx + 200) - logger.debug(f" Position: {idx}, Context: {repr(logs[start:end])}") - found_any_keyword = True - break - - if not found_any_keyword: - logger.warning("⚠️ None of the report keywords found in logs.") - logger.debug(f"Searched for keywords: {report_keywords}") - # Show more samples to help diagnose - logger.debug("Sample lines from different parts of logs:") - lines = logs.splitlines() - if len(lines) > 100: - # Show lines from 25%, 50%, 75% positions - for pct in [25, 50, 75]: - idx = len(lines) * pct // 100 - logger.debug(f" Line {idx} ({pct}%): {repr(lines[idx][:150])}") - # Check timestamp around expected score time - if '05:22:34' in logs or '05:22:33' in logs or '05:22:35' in logs: - logger.debug("✓ Found timestamps around 05:22:34 in logs") - # Show lines with this timestamp - matching_lines = [line for line in logs.splitlines() if '05:22:34' in line or '05:22:33' in line or '05:22:35' in line] - logger.debug(f"Found {len(matching_lines)} lines with timestamps 05:22:33-35. First 5:") - for line in matching_lines[:5]: - logger.debug(f" {repr(line[:200])}") - else: - logger.debug("✗ No timestamps around 05:22:34 found") all_matches = [] matched_pattern = None - # Debug: Test simple Cyrillic search to verify encoding works - if 'ПРЕДВАРИТЕЛЬНАЯ' in logs: - logger.debug("✓ Simple string search for 'ПРЕДВАРИТЕЛЬНАЯ' works") - # Show the actual line containing it - for line in logs.splitlines(): - if 'ПРЕДВАРИТЕЛЬНАЯ' in line: - logger.debug(f" Found in line: {repr(line[:150])}") - # Show bytes of this part of line - idx_start = line.find('ПРЕДВАРИТЕЛЬНАЯ') - sample = line[idx_start:idx_start+60] - logger.debug(f" Sample bytes: {sample.encode('utf-8')}") - break - else: - logger.warning("✗ Simple string search for 'ПРЕДВАРИТЕЛЬНАЯ' failed - encoding issue?") - # Try each pattern until we find matches for idx, pattern in enumerate(patterns, 1): - logger.debug(f"Trying pattern {idx}/{len(patterns)}: {repr(pattern)}") - # Debug: show pattern bytes to check encoding - logger.debug(f" Pattern bytes (first 100): {pattern.encode('utf-8')[:100]}") + logger.debug(f"Trying pattern {idx}/{len(patterns)}: {pattern}") try: # Search across all lines matches = re.findall(pattern, logs, re.MULTILINE | re.IGNORECASE) - logger.debug(f" Found {len(matches)} matches") if matches: - logger.info(f"✓ Pattern {idx} matched {len(matches)} time(s): {pattern}") - logger.debug(f"Matched values: {matches}") + logger.info(f"✓ Pattern {idx} matched {len(matches)} time(s)") all_matches = matches matched_pattern = pattern break - else: - logger.debug(f"✗ Pattern {idx} had no matches") - # Debug: show lines containing keywords for troubleshooting - if 'ОЦЕНКА' in pattern or 'ЖУРНАЛ' in pattern or 'ПРЕДВАРИТЕЛЬНАЯ' in pattern: - lines_with_keyword = [line for line in logs.split('\n') if 'ОЦЕНКА' in line or 'ЖУРНАЛ' in line] - if lines_with_keyword: - logger.debug(f"Found {len(lines_with_keyword)} lines containing ОЦЕНКА or ЖУРНАЛ. First 3:") - for line in lines_with_keyword[:3]: - logger.debug(f" {repr(line[:200])}") # repr to show invisible chars - else: - logger.debug("No lines found containing ОЦЕНКА or ЖУРНАЛ keywords") except re.error as e: logger.warning(f"Invalid regex pattern '{pattern}': {e}") continue From 1380148b335b3b44011ce9f3a2f2a3d23b1ca6dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 10:29:07 +0000 Subject: [PATCH 13/30] Add comprehensive course configuration documentation Created docs/COURSE_CONFIG.md with complete reference for all supported YAML configuration options: **Top-level sections:** - course: General course info (name, semester, university, etc.) - course.github: GitHub integration (organization, teachers) - course.google: Google Sheets integration (spreadsheet, columns) - course.staff: Teaching staff list - course.labs: Lab configurations (per lab settings) - misc: System settings (timeouts, etc.) **Lab-level options:** - Basic: github-prefix, short-name - CI/CD: ci (workflows/jobs configuration) - TASKID: taskid-max, taskid-shift, ignore-task-id - Penalties: penalty-max, penalty-strategy (weekly/daily) - Score extraction: score.patterns (regex list for extracting points) - File checks: files, forbidden-modifications - MOSS: language, max-matches, local-path, additional, basefiles - Requirements: report (required sections) - Validation: commits, issues (custom validation rules) **Key features documented:** - Score extraction with regex patterns and decimal separator auto-detection - Penalty strategies (weekly vs daily) - TASKID validation with shift calculation - CI workflow filtering - MOSS plagiarism detection configuration - Custom validation rules for commits/issues Updated CLAUDE.md to reference new documentation. --- CLAUDE.md | 2 +- docs/COURSE_CONFIG.md | 560 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 docs/COURSE_CONFIG.md diff --git a/CLAUDE.md b/CLAUDE.md index e36ef0c..ea6b75a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ See `docs/PROJECT_DESCRIPTION.md` for full project documentation. - **Backend**: Single file `main.py` (FastAPI monolith, ~750 LOC) - **Frontend**: `frontend/courses-front/` (React 19 + Vite) -- **Courses**: `courses/index.yaml` + individual YAML files +- **Courses**: `courses/index.yaml` + individual YAML files (see `docs/COURSE_CONFIG.md` for all options) - **Tests**: `tests/` (pytest) ### Development Commands diff --git a/docs/COURSE_CONFIG.md b/docs/COURSE_CONFIG.md new file mode 100644 index 0000000..b7e6f26 --- /dev/null +++ b/docs/COURSE_CONFIG.md @@ -0,0 +1,560 @@ +# Опции конфигурации курса + +Этот документ описывает все поддерживаемые опции в YAML конфигурации курса. + +## Структура файла + +```yaml +course: + # Общая информация о курсе + +labs: + "0": + # Конфигурация лабораторной работы 0 + "1": + # Конфигурация лабораторной работы 1 + +misc: + # Дополнительные настройки +``` + +--- + +## Секция `course` + +Общая информация о курсе. + +### `name` (обязательно) +**Тип:** `string` +**Описание:** Полное название курса +**Пример:** +```yaml +name: Операционные системы +``` + +### `alt-names` +**Тип:** `list[string]` +**Описание:** Альтернативные названия курса (для поиска/фильтрации) +**Пример:** +```yaml +alt-names: + - OS + - Operating systems + - ОС +``` + +### `semester` +**Тип:** `string` +**Описание:** Семестр проведения курса +**Пример:** +```yaml +semester: Осень 2025 +``` + +### `university` +**Тип:** `string` +**Описание:** Название университета +**Пример:** +```yaml +university: ИТМО +``` + +### `email` +**Тип:** `string` +**Описание:** Контактный email преподавателя +**Пример:** +```yaml +email: teacher@university.edu +``` + +### `timezone` +**Тип:** `string` +**Описание:** Часовой пояс для расчёта дедлайнов +**Пример:** +```yaml +timezone: UTC+3 +``` + +--- + +## Секция `course.github` + +Настройки интеграции с GitHub. + +### `organization` (обязательно) +**Тип:** `string` +**Описание:** Название GitHub организации с репозиториями студентов +**Пример:** +```yaml +github: + organization: suai-os-2025 +``` + +### `teachers` +**Тип:** `list[string]` +**Описание:** Список преподавателей (имена и GitHub username'ы) +**Пример:** +```yaml +github: + teachers: + - "Mark Polyak" + - markpolyak +``` + +--- + +## Секция `course.google` + +Настройки интеграции с Google Sheets. + +### `spreadsheet` (обязательно) +**Тип:** `string` +**Описание:** ID таблицы Google Sheets (из URL) +**Пример:** +```yaml +google: + spreadsheet: 1BoVLNZpP6zSc7DPSVkbxaQ_oJZcZ_f-zyMCfF1gw-PM +``` + +### `info-sheet` (обязательно) +**Тип:** `string` +**Описание:** Название листа с информацией о студентах и оценками +**Пример:** +```yaml +google: + info-sheet: График +``` + +### `task-id-column` (обязательно) +**Тип:** `integer` +**Описание:** Номер колонки с номерами вариантов (TASKID). Нумерация с 0. +**Примечание:** В конфиге указывается 0-based индекс, система автоматически конвертирует в 1-based для gspread API. +**Пример:** +```yaml +google: + task-id-column: 0 # Колонка A +``` + +### `student-name-column` (обязательно) +**Тип:** `integer` +**Описание:** Номер колонки с именами студентов. Нумерация с 0. +**Пример:** +```yaml +google: + student-name-column: 1 # Колонка B +``` + +### `lab-column-offset` +**Тип:** `integer` +**По умолчанию:** `0` +**Описание:** Смещение колонок с лабораторными работами относительно `student-name-column`. +**Пример:** +```yaml +google: + lab-column-offset: 1 # Лабы начинаются через 1 колонку после имени студента +``` + +--- + +## Секция `course.staff` + +Список преподавателей и ассистентов. + +**Тип:** `list[dict]` +**Описание:** Информация о преподавателях для отображения на frontend +**Пример:** +```yaml +staff: + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лектор + - name: Иванов Иван Иванович + title: ассистент + status: лабораторные работы +``` + +--- + +## Секция `course.labs` + +Конфигурация лабораторных работ. Ключ - номер лабораторной работы (строка). + +### `github-prefix` (обязательно) +**Тип:** `string` +**Описание:** Префикс названия репозитория. Полное имя репозитория: `{github-prefix}-{username}` +**Пример:** +```yaml +labs: + "1": + github-prefix: os-task1 # Репозиторий: os-task1-studentname +``` + +### `short-name` (обязательно) +**Тип:** `string` +**Описание:** Краткое название лабораторной работы (для заголовка колонки в Google Sheets) +**Пример:** +```yaml +labs: + "1": + short-name: ЛР1 +``` + +--- + +## CI/CD опции + +### `ci` +**Тип:** `list` или `dict` +**Описание:** Настройка проверки GitHub Actions workflows + +**Формат 1 (простой):** +```yaml +ci: + - workflows # Проверяет все найденные workflows +``` + +**Формат 2 (с указанием конкретных jobs):** +```yaml +ci: + workflows: + - run-autograding-tests + - cpplint + - build (MINGW64, MinGW Makefiles) +``` + +**Примечание:** Если указаны конкретные jobs, будут проверяться только они. Если не указаны, используются DEFAULT_JOB_NAMES из `ci_checker.py`. + +--- + +## TASKID опции + +### `taskid-max` +**Тип:** `integer` +**Описание:** Максимальный номер варианта для валидации +**Пример:** +```yaml +taskid-max: 25 +``` + +### `taskid-shift` +**Тип:** `integer` +**По умолчанию:** `0` +**Описание:** Смещение для расчёта ожидаемого TASKID. Формула: `expected = (taskid_from_sheet + shift - 1) % max + 1` +**Пример:** +```yaml +taskid-max: 20 +taskid-shift: 4 +# Студент с TASKID=1 → ожидается (1+4-1)%20+1 = 5 +``` + +### `ignore-task-id` +**Тип:** `boolean` +**По умолчанию:** `false` +**Описание:** Отключает проверку TASKID из логов +**Пример:** +```yaml +ignore-task-id: True +``` + +--- + +## Штрафы (Penalty) + +### `penalty-max` +**Тип:** `integer` +**По умолчанию:** `0` +**Описание:** Максимальное количество штрафных баллов +**Пример:** +```yaml +penalty-max: 9 +``` + +### `penalty-strategy` +**Тип:** `string` +**Возможные значения:** `"weekly"`, `"daily"` +**По умолчанию:** `"weekly"` +**Описание:** Стратегия начисления штрафов: +- `weekly`: 1 балл за каждую начатую неделю просрочки +- `daily`: 1 балл за каждый день просрочки + +**Пример:** +```yaml +penalty-strategy: weekly +``` + +--- + +## Извлечение баллов (Score) + +### `score.patterns` +**Тип:** `list[string]` +**Описание:** Список regex паттернов для извлечения баллов из логов CI. Паттерны пробуются по порядку, используется первый совпавший. Первая capturing group `()` должна содержать число баллов. + +**Важно:** +- В YAML используйте одинарные кавычки `'...'` для паттернов +- Backslash в regex НЕ нужно экранировать дважды (в одинарных кавычках YAML backslash литеральный) +- Паттерны выполняются с флагами `re.MULTILINE | re.IGNORECASE` +- Принимаются оба разделителя `.` и `,` в числах +- Если паттерны заданы, но баллы не найдены → ошибка + +**Пример:** +```yaml +score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + - 'ИТОГО:\s*(\d+(?:[.,]\d+)?)\s*баллов' # "ИТОГО: 100 баллов" + - '##\[notice\]Points\s+(\d+(?:[.,]\d+)?)/\d+' # "##[notice]Points 10/10" + - 'Score\s+is\s+(\d+(?:[.,]\d+)?)' # "Score is 10.5" + - 'Total:\s+(\d+(?:[.,]\d+)?)' # "Total: 10" +``` + +**Формат вывода в Google Sheets:** +- `v@10.5` - балл с десятичным разделителем (автоопределение из locale таблицы) +- `v@10.5-3` - балл со штрафом +- `v-3` - только штраф (если score не настроен) +- `v` - просто зачёт (без score и штрафа) + +--- + +## Проверка файлов + +### `files` +**Тип:** `list[string]` +**Описание:** Список обязательных файлов в репозитории студента +**Пример:** +```yaml +files: + - lab1.sh + - README.md +``` + +### `forbidden-modifications` +**Тип:** `list[string]` +**Описание:** Список файлов/директорий, которые студент не может изменять +**По умолчанию:** Если не указано, автоматически запрещается изменение `test_main.py` и `tests/` (если они в `files`) +**Пример:** +```yaml +forbidden-modifications: + - test_main.py + - tests/ + - .github/workflows/ +``` + +--- + +## MOSS (Проверка плагиата) + +### `moss.language` (обязательно) +**Тип:** `string` +**Описание:** Язык программирования для проверки MOSS +**Возможные значения:** `c`, `cc`, `python`, `java`, и др. +**Пример:** +```yaml +moss: + language: cc +``` + +### `moss.max-matches` +**Тип:** `integer` +**По умолчанию:** `250` +**Описание:** Максимальное количество совпадений для отображения +**Пример:** +```yaml +moss: + max-matches: 1000 +``` + +### `moss.local-path` +**Тип:** `string` +**Описание:** Путь к директории с файлами в репозитории (если файлы не в корне) +**Пример:** +```yaml +moss: + local-path: lab3 +``` + +### `moss.additional` +**Тип:** `list[string]` +**Описание:** Список дополнительных GitHub организаций для проверки (старые годы обучения) +**Пример:** +```yaml +moss: + additional: + - suai-os-2023 + - suai-os-2024 +``` + +### `moss.basefiles` +**Тип:** `list[dict]` +**Описание:** Базовые файлы (шаблоны), которые исключаются из проверки на плагиат +**Пример:** +```yaml +moss: + basefiles: + - repo: teacher/template-repo + filename: lab3.cpp + - repo: teacher/template-repo + filename: examples/helper.cpp +``` + +--- + +## Требования к отчёту + +### `report` +**Тип:** `list[string]` +**Описание:** Обязательные разделы в отчёте (для проверки на frontend) +**Пример:** +```yaml +report: + - Цель работы + - Индивидуальное задание + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы +``` + +--- + +## Валидация (дополнительные проверки) + +### `validation.commits` +**Тип:** `list[dict]` +**Описание:** Правила проверки коммитов + +**Параметры правила:** +- `filter`: `"message"` (сообщение коммита) или `"console"` (изменённые файлы) +- `contains`: строка для поиска (опционально) +- `min-count`: минимальное количество коммитов + +**Пример:** +```yaml +validation: + commits: + - filter: message + contains: lab5 + min-count: 3 # Минимум 3 коммита с упоминанием "lab5" + - filter: console + min-count: 1 # Минимум 1 коммит с файлом "console" +``` + +### `validation.issues` +**Тип:** `list[dict]` +**Описание:** Правила проверки issues + +**Параметры правила:** +- `filter`: `"message"` (заголовок/тело issue) +- `contains`: строка для поиска +- `min-count`: минимальное количество issues + +**Пример:** +```yaml +validation: + issues: + - filter: message + contains: lab6 + min-count: 3 # Минимум 3 issues с упоминанием "lab6" +``` + +--- + +## Секция `misc` + +Дополнительные настройки системы. + +### `requests-timeout` +**Тип:** `integer` +**По умолчанию:** `5` +**Описание:** Таймаут для HTTP запросов к GitHub API (в секундах) +**Пример:** +```yaml +misc: + requests-timeout: 10 +``` + +--- + +## Полный пример конфигурации + +```yaml +--- +course: + name: Операционные системы + alt-names: + - OS + - ОС + semester: Осень 2025 + university: ГУАП + email: teacher@university.edu + timezone: UTC+3 + github: + organization: suai-os-2025 + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 1BoVLNZpP6zSc7DPSVkbxaQ_oJZcZ_f-zyMCfF1gw-PM + info-sheet: График + task-id-column: 0 + student-name-column: 1 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: ст. преп. + status: лектор + + labs: + "1": + github-prefix: os-task1 + short-name: ЛР1 + taskid-max: 25 + taskid-shift: 0 + penalty-max: 6 + penalty-strategy: weekly + ci: + workflows: + - run-autograding-tests + - cpplint + files: + - lab1.sh + forbidden-modifications: + - test_main.py + - tests/ + score: + patterns: + - 'Score:\s*(\d+(?:[.,]\d+)?)' + - 'Total:\s*(\d+(?:[.,]\d+)?)\s*points' + moss: + language: c + max-matches: 1000 + local-path: lab1 + additional: + - suai-os-2024 + basefiles: + - repo: teacher/templates + filename: lab1.sh + report: + - Цель работы + - Индивидуальное задание + - Результат выполнения работы + - Исходный код программы + - Выводы + validation: + commits: + - filter: message + contains: lab1 + min-count: 2 + +misc: + requests-timeout: 5 +``` + +--- + +## Примечания + +1. **Кодировка:** Все YAML файлы должны быть в UTF-8 +2. **Regex паттерны:** В одинарных кавычках YAML backslash литеральный, экранирование не требуется +3. **Индексация колонок:** В конфиге 0-based (A=0, B=1), система автоматически конвертирует для Google Sheets API +4. **Локаль Google Sheets:** Десятичный разделитель для score автоматически определяется из настроек таблицы +5. **GitHub API encoding:** Система автоматически обрабатывает UTF-8 логи с Cyrillic символами From b96b49e8a27537c31b28b74b224537409a4ac2eb Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 3 Dec 2025 14:03:28 +0300 Subject: [PATCH 14/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 0ae2c9e..d47c8bc 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -9,7 +9,7 @@ course: - ОснСтат semester: Осень 2025 university: ИТМО - email: k43guap@ya.ru + email: mdpoliak@itmo.ru timezone: UTC+3 github: organization: itmo-fs-2025 From 6570a192849d149528ef6b88cc96a1a325fc5b02 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 14:22:17 +0000 Subject: [PATCH 15/30] Fix penalty calculation for deadlines without time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Deadlines specified as dates only (e.g., '19.11.2025') were parsed as midnight (00:00:00) instead of end of day (23:59:59). This caused incorrect penalty calculations: Example 1 (incorrect before fix): - Deadline in sheet: 19.11.2025 - Parsed as: 19.11.2025 00:00:00 UTC+3 - Tests passed: 2025-11-18T22:55:34Z = 19.11.2025 01:55:34 UTC+3 - Result: 01:55:34 > 00:00:00 → late by ~1.5 hours → penalty -1 - Expected: No penalty (submitted before end of deadline day) Example 2 (incorrect before fix): - Deadline: 19.11.2025 00:00:00 - Submitted: 03.12.2025 10:00 - Delta: 14 days 10 hours - Calculation: 14 // 7 = 2, 14 % 7 = 0, but seconds > 0 → rounds up - Result: 2 + 1 = 3 weeks → penalty -3 - Expected: penalty -2 Solution: When deadline is parsed without explicit time, set time to 23:59:59 (end of day). This makes deadlines like '19.11.2025' mean 'until 23:59:59 on that day' as users expect. After fix: - Deadline: 19.11.2025 23:59:59 UTC+3 - Example 1: 01:55:34 < 23:59:59 → no penalty ✓ - Example 2: 13 days delay → (13//7 + 1) = 2 weeks → penalty -2 ✓ The weekly penalty calculation formula is correct and unchanged: - 1-7 days late = -1 point - 8-14 days late = -2 points - 15-21 days late = -3 points etc. --- grading/sheets_client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/grading/sheets_client.py b/grading/sheets_client.py index b454fcd..f8eef9a 100644 --- a/grading/sheets_client.py +++ b/grading/sheets_client.py @@ -280,6 +280,12 @@ def get_deadline_from_sheet( logger.warning(f"Could not parse deadline '{cell_value}' at row {deadline_row}, col {lab_col}") return None + # If date was parsed without time (midnight), set to end of day (23:59:59) + # This ensures deadlines like "19.11.2025" mean "until the end of that day" + if parsed_dt.hour == 0 and parsed_dt.minute == 0 and parsed_dt.second == 0: + parsed_dt = parsed_dt.replace(hour=23, minute=59, second=59) + logger.debug(f"Deadline parsed without time, set to end of day: {parsed_dt}") + # If datetime already has timezone info, return as-is if parsed_dt.tzinfo is not None: logger.debug(f"Deadline already has timezone: {parsed_dt}") From 5bf6357ef463927611d4b2b111ecdd665bb3570e Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 07:59:19 +0300 Subject: [PATCH 16/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index d47c8bc..7390026 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -84,5 +84,20 @@ course: - Результат выполнения работы - Исходный код программы с комментариями - Выводы + "2": + github-prefix: fs-lab2 + short-name: ДЗ2 + taskid-max: 10 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + ci: + - workflows + files: + - lab2_distributions.ipynb + moss: + language: python + max-matches: 100 + local-path: lab2 misc: requests-timeout: 5 From 0dc5a50da1779ed77b3ea6e73028753832d3f1c2 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 08:11:57 +0300 Subject: [PATCH 17/30] Create machine-learning-2025.yaml --- courses/machine-learning-2025.yaml | 157 +++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 courses/machine-learning-2025.yaml diff --git a/courses/machine-learning-2025.yaml b/courses/machine-learning-2025.yaml new file mode 100644 index 0000000..d6858fd --- /dev/null +++ b/courses/machine-learning-2025.yaml @@ -0,0 +1,157 @@ +--- +# Configuration file for lab-grader + +course: + name: Машинное обучение + alt-names: + - Машинное обучение + - МО + - Machine learning + - ML + semester: Осень 2025 + university: ИТМО + email: mdpoliak@itmo.ru + timezone: UTC+3 + github: + organization: itmo-ml-2025 + teachers: + - "Mark Polyak" + - markpolyak + google: + spreadsheet: 13AEYDlW989lUwhIF6p3KrWlw2Iofoy2YuL55V2urIaw + info-sheet: График + task-id-column: 0 + student-name-column: 1 + lab-column-offset: 1 + staff: + - name: Поляк Марк Дмитриевич + title: преп. практики + status: лектор + - name: Поляк Марк Дмитриевич + title: преп. практики + status: лабораторные работы + labs: + "0": + github-prefix: ml-lab0 + short-name: ЛР0 + penalty-max: 5 + ignore-task-id: True + ci: + - workflows + files: + - goals.md + - info.md + - report.pdf + moss: + language: c + report: + - Задание + - Результат + validation: + commits: + - + filter: console + min-count: 1 + "1": + github-prefix: ml-lab1 + short-name: ЛР1 + # taskid-max: 40 + ignore-task-id: True + penalty-max: 2 + ci: + workflows: + - run-autograding-tests + - Test python scripts + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab1 + report: + - Цель работы + - Индивидуальное задание + - Описание входных данных + - Результат выполнения работы + - Исходный код программы с комментариями + - Выводы + "2": + github-prefix: ml-lab2 + short-name: ЛР2 + # taskid-max: 20 + ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab2 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "3": + github-prefix: ml-lab3 + short-name: ЛР3 + # taskid-max: 20 + ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab3 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "4": + github-prefix: ml-lab4 + short-name: ЛР4 + # taskid-max: 20 + ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab4 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы + "5": + github-prefix: ml-lab5 + short-name: ЛР5 + # taskid-max: 20 + ignore-task-id: True + penalty-max: 2 + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 1000 + local-path: lab5 + report: + - Цель работы + - Задание на лабораторную работу + - Результат выполнения работы + - Выводы +misc: + requests-timeout: 5 + + From 74a3fa0dc0e9647b6a48fe8fc98fbe515d2bb331 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 08:15:27 +0300 Subject: [PATCH 18/30] Update index.yaml --- courses/index.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/courses/index.yaml b/courses/index.yaml index c288be0..9504400 100644 --- a/courses/index.yaml +++ b/courses/index.yaml @@ -29,10 +29,16 @@ courses: - id: "ml-2025-spring" file: "machine-learning-basics-2025.yaml" - status: "active" + status: "archived" priority: 90 logo: "/courses/logos/mlb-2025_logo.png" + - id: "ml-2025-autumn" + file: "machine-learning-2025.yaml" + status: "active" + priority: 90 + logo: "/courses/logos/ml-2025_logo.png" + - id: "fs-2025-autumn" file: "fundamental-statistics-2025.yaml" status: "active" From d39e9aebbbdc84e266473462b1339b353f170426 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 08:18:02 +0300 Subject: [PATCH 19/30] Add files via upload --- courses/logos/ml-2025_logo.png | Bin 0 -> 57773 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 courses/logos/ml-2025_logo.png diff --git a/courses/logos/ml-2025_logo.png b/courses/logos/ml-2025_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ef386a2ddc3af040664c52cb05adb79a4a44c14c GIT binary patch literal 57773 zcmeGEXH?Tm*9Qzk3<@emP>_x&M!F!qMp00@^bSXQfPhq~Az(*}bm>TwF1-^tD!oYw zy(omt64N_rtT+^F>`O{v?wvAGyZ(mAinXRF%Tl67q&#u|BNvJzX|{DgCFqe|NK6cLi~$j3go||p<*eg zemxUDhSUm!gI{D$_w`+fh|bXveu#u3GnD(>G~Jj69;>wi^LOY_J2l5fM>$X{OoN13~{xSX4g|uXOnYuwqz6GyUBNx zUFIws8=I7~g_VTHUHSif9Q-BCZsY3eB*D+`;o-sOaf{E<*_vNKTwI+0rXatdATJof z>*9%Y{p$%Y(&g&EME*ts|0+K=-dlj&82f?CgYze*XJ+ovyZ4zh*+Z z{AXKWgZzYd_yzcG^8cg_J}O0cRYJ|#))LH2pf4jJ_0P!vdF?;*NbwUU|7|ho6K2ylW%8;h~PvI?%vjVLcBbFQZ@ED2{vVwwC~+`;j)PTmuokhXc`!< z`=7HXqPtIYp3LmJ|F>e|^H!%So8pPAAeT+mAX6$+1+sG!f)d637*(a5a1-fn@$r0} z+?}n{YHT>9_}y4_F zpkk%JFP1Pm#gmvgWHwiw`2WxV%XRp_Q~g;kfuZO>+$D(XA7S_-3_tbDA7S_-41a{- zCkOrr!%tTH(F}it;g2x<^n1Am0! zCoBGFhCjmaM;Lx`;QtR{h`Y46WO_1UKVg~hEBSzKKTGOlgzXz4rK(J6NF{1wbgX^c z*q`_Mw;|CWhhR`3?FsuxjV^*eCu&mU(i8ZVBi?n+SS;_9EzS{$Qp;@YA&ORHhu$oYrgZchYgQVZ~+ywT_PzWD} z^;E+j<{=Q-XSS^9GApw>bH+uoeq^nCMq10{WtAHN+qTN_n*Fh zBj^I#&0siE`axmPScvliXGMX}?zjiixJoE*`&Y8)gF49j{H^$w17digC6-|q>_Mbl zp7T(}%PG=rMxajIZ|~cS@kB|F>Jn+#Z!EQh_}#O#yp$sbMNkiU1hw{Msd=o`?M|%j zZ_WkkPwWmiQoiX>rs7!eIl=EFsZPE{8(JMsoP8IC@GO zl8?8ta+3oSOQ`TgQq)HKJEM+s9Ra1kB#kQeb^|ZhEjFb9A~c4qcC$;eWM|^COLsn3 zcarod?(5y(HYq{aq=GaT33`#F*W0|yP;&=8LmMSmCw3q$yOZDT{cFLbJp3tVnm63o z&Ok-3eAHSCE;7~r8e~q>se0w-v5H&+L!NUPJ|yGeSnpGl<;8lN=fUK?9!vQiY`wOy zf0t34uJqgV3qa(v;`gD%5E$vTC+jn<5i~N7hws&3-uD{2%%Z@d=$14^FYv92|0b+% z=SZFUjb)t`QasU6%HF^<$xeFp%S(xG_whb%oAbX9bs)POn3jJKZU|8!yY@27tSR5H z!o7+ky=h_46t!)~C9LyC>X(#>J%m$+&@}R>`ZxT5WMs+bHwH(h&(<>YJ3RR9*8Q6k zyngA)&hl{j5@w-|L=4F_zxj(%iy+1LA&q4wpaDUm$RA>GP7lIlZtm6}=eW=QOE#yf z{fV00C<}o``Pg9SSm*Xl*u>q914Z_k)Y%A&1)Ao+g4MwZG)Go>DXG0W8}U=>yB~`7 z95MKvT>F8E(HU)d=_|hx6$Z)4!TTL-@Tp!%*=aEZWxY`~OJ3+tQEG?-Hx_GD(i`DYT@7YlEMTp=`jG+6k2%`N;VE-vINa(}(>2d!`^99^Wq0JmuV+cA zVpXOl>bz@`{SIu)x02|?iE7Jj1ttdcI3ktEj&@#kC@3WPa>?cukzls@`7AiAh$)-v3^Js}7qZg>A zhE$EYInh?o-n2BR1i?^zoRjym8^fSb9%~bI<3QL#xvaecBS-Z@4*Z|}c0L~rfDKq& zd@BxtvafF~^o@f{P_8tUrc8fxYo%hc{@~UVn}J?Z{QbSgUW z%D;4VtWiVZ2W3@)-0^Wvm4_=K4CsC?6unJK>Xeto7xfgr3CTKAY^!>v!r+qbf;{#K z`Ryn-p=0lzlj7BWG4kIVdx>nm@!>Po{(8^FL<+Nm};uLmy&o_^VRtUp1MP6skf;J~=~{u2mj+l(>|i zz?}GtZBVQ}s@Icbe5>0hH|M%NY;WziZ(4UJC6-iw?IxJTn$ZeJQxeGyV?O{n3U`|8 z2h%Jjfa)X#O7vEaCc znSRTh{9q}+qrG%Cy%DdG+E)!KB&rVH;*Da$H=O&Vut)pdGKcoNouZTd311dBq8)y~ zl6^Fc>!aIxvL_oeHasI-DM_RN<(P3bS$t``c>sg#$F)Nq=&Zr0w?y;R%qDlP-VR)iWW$Hb9#^&t$U&qQ=-nf-86)Ql ztnK|g-W~sd@47AA(+FXu9#aXM4y2SB%J4v%?6&J*jc4g%HmYma-V&h+<~Xm$55{lo zVJ+nOi2CxS7Gn6qy<62i5XYG|rRwBz3Go|*#AogJ^b+0Q%`INyVXtij23 zggTq<|Xejg+i?o;Pwnv2dv-G}6ifvQ6@uGbkUXwLq?I?rkowLtdGXhv+`1ly?Yq?b`EIgBPruOiD#Mq6t(qLKc3a}QyuSYJQ!Y`XN}FHnibe=I(%C0$ z11C~8Cg=t9Qm&abgzY*&0i;egr=SdUEfms{2P2tGt^g7qyGERfJFd?3@||ZZcqXW9 zXx z-RHC8rCQB-x4L4ARVky4)LS1bG{qLFRN9gsNTJJtO&lqZt%7|#T+A)Z(Q1({mOH~Q zw~63xNDVBZZBviWKC0z&`jA+6Oy=?&dQ{vI);2jCC6c+@RQd2E@ewxJbaM$R;LH<7 zbF_uHEZN99;z8ainUN=FB5G{i+wJuND;-&Z^dg(#sH6!()~xZ}Dy0C-)e8k0%WF~N zfyj-pz__AQ@2bR_DByy7lSAGvI28#fX`U(Dsq5~A8ikn8SuyHs!F`>x2cK0`Yx{1& zq-mWQ6;Fp5fA@BZI9j{a<>gqsIuvQbOy(-aGMu#{4Oyb5n$pwt7$+zp_hQ-AC#_TSFeNb!mTE6`+8JWoz9#JF9nq-!-j$^4N{L&3mnGZYM~f^zA-J&yUHILlnj;=F4H% z)!du$L}+;c#Tl}13}5S+B%Z@Yp8q(QtZ0^o@j+E(n`F(&7x26THtS{vd>G3S%zzw> zFV34Qkc7D@O*TD2PE@Q{AsJb?9Cz_s{VkQ$&10{7@nbz+6%zb>96fK@9wu3Tcl$EM ziXI>spiHhy7Ap`uv(rhA(M0#820HkA@~EaVFut({DVY3Rw+37`^G<$pt4yNEMEip$ zr#n0J7@I?izMddUANDS|47;_GyobK=QONX&fuO5^49(KUu%KxGyQf#1lVnl#?E?v` zk(SNTYkZ;uo7DdM0A*z<{_O>{|4pv2q_fRQL?HzhmX7mvU)`V0iu7*@sfW%Dnv09j z8_IcozSi^5%jFHPwG1(21uVBzQ`forHoAy;bGkV+x9`3at}9Ve7d2q1`W(2tPaEnV zzY-zt;j{>tQXP?Ci;`ikzWLTmsWA9x#;$4nM&FRYZO_T+C5T)-KI417sDkMiJID%f zx=Rf`oa_HWyJqM8fGey8lZz_egQ=ta8|6`jPyP5YlaoL4uA`6Zk6I&OgI*3yE&(Q* zo(5e?UM{Z9vo=*c8>(jQKpG4Sd@UU{*o?JZ%2LY%lYqUl+ig6 zVYIa>vLv+SRdF^jGb4}qOe~mkiwyHa^cAEzkhfs;@mGNXlJ@GiBA$sOLMIZvkcX!T zppU2Cms!X*21CUO2cxHva-JbJqVag(2gjzco;{8Q znu#N!W;>_R(%3MwbAg>Uy+!1+q!zN*PexmtQveLxI&-i1l?OkS1V?k6)R4 zRpGeoQO9wO)s~&D#kt+vgYMZS%Y%L9DOg``e0U%3CUI7`?1@h`H18b|`T?ML{jV|& zNDNKOZFMr|{t@qK0!muc@vhP^B!IFInj53dtG%+vd*-{ioaEj1m zaMsL_&5K@6f88RZiq(Sqn`@YG!<@v?29#L9g6Z*<(F%TDbvptWx&V2hdVeQa?(uwy z!ZlMId)X>1=d-~LO)})nUGdvuurKGd|E6!vETEp*n?oJX2a`F0gb+5vE!08Oa{ z>DXO1$pNExxsThX-c$4<^5{L;KRTf8Z?(IZu=eOpi*O&lVsJDcbLXx#Ip}`~s3!W= z)Yg~k3X?@vbGgMTfYWgUqTjF2?|g464xYcO#b~r;AnbYDtPjFrqt0U2a3e4n8=0k4 z6-iT2TuSt!j1>nV(%Nl&=!XlWmd+saa_>cNdv%zKCe*1z* zIWOeQ=b=&xbiXB1SKtDhdNK%g_`0zxFkYHxrMmRIsPSHNG=FmZw7?mAf|=YRK@7=o zs2S4R3*BCiR!wS#oWi?n9WI%V?qiPHF+$q1Wi^B0m^WGBk6XaS>E{3tSl73O96bm{ zFbk~`pazci5#XeJBqsxbxF0QA%kT2e)wz>w zjN4`JYSr{Qt4=)u+nvtmd0^&_He$Y9BW<}r^HTQc8>P2&bl2oWhi3#4I_TV3mCM}s zrP|MDOURi=Lrfmwl?ty)iz1kfgfJh=aK4QoLQvW&@+KulDnj z8omHk%BPBW2IYgTgF6*cU>}nE$lP`&W&0DQEK{f9#O1@~2};5&RwMwSnp#ej@QzgQ z<_q@0EOKTi7!4QdWiNhiC3&5&H$s=x6U%5K)O?qB{5tk#Sssnp4Yr2IVJ#47^o5ob ziwwK%9^&2};(-hoiRkA=qI|p#zR(f6Cjc*d?6(V*c2;|tqfx^l8D|ulrZ10X@`mLH zap>Ev1&{r`*9uEE%1aO~{4hD;^4^dSl2_C@U__^c;~kC3FWt>}lc6-F2N-3Z!J}Yi zg@&mm)iU5Q<)_kt*X?XY-U<%61TNm#J|yL7(k_xa!wq9y`0`M5g70B68Bz*$d_zEnaqx@4};KhCyJ_W)}c|zKB<7Ev5S2%yvCeJH%v_bVlUkl)a#?5d@pm z?JNWDf%C#IvoVzVC)u`JF>4qWE(P9AXT$I*9p17ltcE}#4Iw(YJq93EXVsS|1YtKb z-OlxU{auROHZFtZWL09ev4(|s`|si~v8;5@j$+%sg)eU%?1K7wJOmG4a3Nx|mIE>t}{9h|%GZyut!%-Y}ac9zLDugu70Xj~|GsPLQj0IR)_Wln}sexgsb zi11b&6PJ>HfmC$2+KQt~rWq)F;Xg&1$%FTyjB@1NJlKvbreyBVORW`-$t|sl z+kDa|qTcqTZ<}X--yXIxxMHl{?s46-?GkoN$kuo9Ai6kmP2Q7dbI#|DL(`+-k^Yf< z&gOo#k%zlAqrORAUH3wdaQh%`R)ADn;)_@_I3~Dfhuedu9{ZN>YGvHHmJEueS>p$f zovXAtq)pn(Gxc_2Hi3z32abW!L_Y%=6K`0e2Z}+$W`)0`e<^=LbG$I?NO^T6b5*<9 zSzTX=-i4^HDSNU2tJOq3b;BV$lP1zg-@(@~I$~kf!~Ipc=3;rWdjKOYz2L;eB-9sa zGaNk|mHae6JKywgeq5`F*yMu8q_2m^TvuW)CRKt(@)iH)W~`Y4gn7T+Y|)d+u3g{H z29Zk65!N@3z19u%(<}%?#5U`5LFGx>LNGjGdsTkk=hG zmD6M&`t}XY?f7Qy>{2`BY!BLZm9@*rR>X6dxh>bDmao-!beo^P(p9~=J8Ye7Y{`E+ zWEn+GHGf9(dHtvS(o}o9-k_^=_g*(vZGUrtk}~nHQ#SJ>KoByMYJPRg@90N!ZlZUv zq+?)^>cd0qgOu^nx*dlsXE#yUO(iusPvssC-4Me>XNX?F+p?%sqjAd$Is;>0h_0~wp6Jw6=wA;WbZR7gH=@A23hDvdVb8;x`Ridd+< zpWF})z(l#$Xq!PDxWYlRDb_)|TA{+R(Ug_tTA4+~SP7KFN)CzxHUHTvyV_>|Y3Xe3 z?vl?3zd>x5ChNG#8R4?cD<`R*;|=o2vSGS@F)h@KiM6arQyKJE)L{jx=B&HxDQ0~4 zz)|(+X+&jWo)x#})tk?N?{P5)`(WxWdU>j^Zu@JR0?f}j=?l%+{R$1>`&Xagw-eAe ze`k;zUp8=CRm<73TzR-_K%;5P|9-_DHq+=rft&1wc3<@R@hf|!9ek~a=`O#r>tt@- z8+?Pj32PjM?+ePhNz;>_jdw9@?LdMCEmG1ZPanNk#9-^L-^>T|m)2udr*5E^J6s0R zv7EZN=M;AkorOL?HNJqKJgs2b8~iBU%8TYz{=ySVhUAehXpPk`C}(vEklNWFnWfvE z$(=OWlvu0qag!l#Nk$#-FAk;6dU2yeppqN4UpQCNv1<-NNgK*jVPLW=SJr4^AEb_o1^>U+9UjPUJ;=^ zf%wcmjy{X7g9tNO%$Yr!hZiuFQZU;u^G1piLQNWMm^Sq#txX3myaST_@KuplUR6l* zhR^bm1|=SE-nlKHRsQCMrWbr`lHYQPsjdptXC*Xbq_Ig%u?Lx$1Z&uk*LU4 zi#;-CH^to*Udpk=_}W(;GzO>Q@kQXTsyBu@0ZhD+FweYw+7g3BZ5dLdJ0NjI{Th3F zN*T>`bD;K<;i&Y0wN6Cn1vV!+bX?8n!LgvUgvtS?rweEPR!VXlvdB4pg6VV(WG%Cl zqT{4)SvpN`*R>XAYO9GW;^`xIj`S>(j+Sbcnw+L>!e@7?`;jJa0h^fxWwmLvoFxQKfQ)GXJ_Emv&rOr z-sYu?wYU8n=pn*qRN8sZ2q*2i74Bg#`adv16!hJSn}5iV!t3m>Cb{j10)n>n0y6Lqs)5@nCfJkuWuvmttHXXGA7`c7! zn#~CFnUqYXx%8{3GL`)Y9lbT_<+U8LAeYG4f$foTbZR_povC|wMYHK%w&z$p=aFJ) zRH}or!%7iUD@aa-I5r#>3N@ewO=VR(Gj;Un<=O+up*Ttd7bgpXY-NxjWEK`fbDb87 zSq)54tZ#Acuhhw5@Nuab&mEeodzLnfe3Dy!=Qf`&PJ`@Y4TvOWEH2M|zw3oPkHaj! zF=cbfSf}5Tx52myo;bjDdpZE3O|_Jr2eTDAxh~v!Md4pc+6n1uI^jf<9)R;c)7V| z-_&q?+0K;g{jBxl8r)>X5%n5JLs5o={@;3WqhNPK?s#te9f;i5Lj!krFGrsK8`qS~ zPC*uAOzen+8-p{N?lDNvD*=G{USnG5A}K4ET(BqRK@v4I*X=dx^u?96PwhqbJoAK4 zVmbyzCw(;Ch_298^)j~HB{&}troKu8ZuCPmlSThQ1gh2bBxlU#HaLGG^i`5-n65Cn z@~|wY>48WTpS6nT!Yp@pjoVQE=&`filW|_t{AsH%;`>M6gZNFZsxk7TaGl6^ZMDME z*IycFvXx;HUv2k1D-a$)7Xv>WXo3g6w!r7t^{1{|Q`@Lzo=GbmJ#hb+`)ip?_xVE@RHceTKU`roPa2PCHrO#W_pDnu(x@*DjyuIP>=C*> zG0zw&xII^KRBdmnNMC)svSTT_>adna7H2N&_#pQUPH<}?(z6H&F$6ilp*y_X)9)ep zL|xQ!utfo;LxUcqLpePcsP(cMhF;y}4Ggv#9ql$JmtID+e9{X#0+}EYdeTDi%D2^v z$vxI47zgVKgDgLNnx{*rzgIWwkIyN2MiN=cPAcIPs-almZWd*ZXhn08R7(~bMdfY} zYCbPG)*|E5gpvyJt83mY^Dd59aZ{CwX0R{SvKQayEVT2=%U+#z<4!Qq_iKZ^i|xW=9w}9>(RUhUhzX zHdc-}dP+163PKS8tA8l7nz4(5l)q8VzqSw?p$E|7z^KhTJ=2n4f8pjWyYxz z!{?lAc4wdN`W~1|DL*_m7}Qtlv2C$#-xi#CILk5b0z$JJ#np!Hh}BD5XFF*i`w;$EAHt9M8B8@(g+ola>$@DbC;UM1t%+c}#?fx$b$&eX;(YUsGam%F2b z9H!Q$`gZi#2-E>E-X++Cm-{_@6F|-TZ60!KMGh_+Z z)y>y}i)qFeZ!g&j+t@;&suyk$_ZM}~e9nt*lz*`y)(lS;;2Qll^Q|%PjGTVsC8mHVA?b!F9r>sCRowoP|KkakL}bR{pJX+thW1Squ;#9cof&agy1 zKbCUIf#Or`#JQ{a<8eO{h|pEgCRLf}5gGSB6}8vme&T8A?;_@p{T>x~iCs@Ds+O27 z$pabC=!t4DaJRu7 z_Zw`u$RSu^@RTcPNsra@?uDiZ?|!x2lcVI5Tk*4?x}>0BKH9(8o{aTv5BAizleH5( z=4xcL{tU>g8ejkRT!Z&^%1FH-8Nw=u*OXj4KacwIMXIUG76K6sIH+@j(WdkO>S&s^ z@T}b#?<$E3U=%{=_CP2s)bzNwPzBR_$BBohpXZ9+r_X##K9g&!p}hF%OKsn%Jh?ng>< zY6l?5p~+@WG$1uJ&xAn;4zApsEC0xQ(x#&dN8z^zxvNw0^HrHKdkOg6$FK1we8MD` z5EMzwS$3R|Z88vu(S3Ey-3RCU|)G{mYU7!4M2MWiqThYvOC!* zL=vaBt#B%_Qjz0?)4uP{?V5&O9JdEC6eH!7Sz?|Na{kdDL6<5^-fykv{?IK~q^-de zmu>mw&DgFs19xcwTbz7x>W1*ffq6v;^<7_(q%}4D5Fn0csW-%jXscf;{>Tm_>5_Kl z`wo6E-4;l5=)PNAwKwfFntIUcQV?sEWoiB^0sxCG2^t1uQnPHM_ zGVJ?xK^6*Y4Q~8z0ho-{Pub#%7avXwS8_6c{aDa>NeL5l@Q^;vHkFr>PlBe%;yUdO zIyVHlld_RdF6`q2z#A2vnmgTB)`3gg{kB8jHZl-fJS}U-Ap;6!K%6nX#Kf^4`nqFWOx~u_fEw22aA!rS02#HkxQkf8m{_J>dfh;%ixQb2x`<*x zj}*2=O)$D|_4Q-Qz|kkcPsj~(Rd9v8HM@`0Uq_m zkA{Vxl)5B6attSDd7t(FjE{9HLJ=}HDxyxhIGx^}i-8S_(6$E(0fihyl3JzC;lTPZ zZSHJW-mn~s30)N=i|&`OWPLj!WIi$O+;8wae@4leNw;sDSigB>`>|d1vIc}HX1i1b z0=(fK3Q{Azso@gqIImCOn-~85E`W%!Twhg@5}LKO7Q0o5pDMlYb0YK(N%d&<*7wUJ z?irCv$sIV6s3MwhBz+Siy{1Fg)K|ECXnC_{U-3H`FqCe$%GT;uoq1&)Q{U{%l!kRP zjNJAZ{pxEXH!*_-5rY3#)oJZUpvIX!3))s{BytXbeg0Sq5BTU)?bxg#^nhxdII(=RBV}HgqEfjp|Bj#wx*)DO?fkr9%2kUX;Uq?m9=IB;)S=j;S z@gxq-jKAuj<=4&HE6W&kCt67m)m($Jq|&ZE65=6*G6p9&+nJ+eP~V2m$VAm7e72G_ zNP-QF1q@A=C&k6*rPXw;ZfXv?Mc`ias!&NauKV`&%-GlC%y}o|g zLV4B(K$2{I^L^t1${Ee`$Vj@MQl9IKf3#TlmF@=!hcJ*F&jBqOWS#3>Jmi~{M5mS{ zB`%5D_u08L?&hAHT=)H!j94VntLJ>DtogQ#U{OO59e)mKq>52TP+mQjg^7+cAqp&5 z1>69J7j#hMb@k1u_f>^;-r8Nn%bklOAZs71%GNmK2QSYwIVHMJzrMAR>vrf3igJuk zzb|O@s@8EIiF=nYt3BEaVJaJVc?8A-oFrjrHxUajXtN}$Hpl|`3<@kFpA(cSu|e=e z3M#kJ&7rKJ6e3<9b`bm9k*U57z$_M8Vq#;O?HrNw_xLFKZKl%kd-8g@10aST%KwxD zSQbUF$Q#$sT@d3@?;B#ok6E3GV__l$hD+Jkn`Mt5>u%x~=FW@6nfQ@CAG}GP_z^`& z2N&xEu;G4;#GCN&sVf;8Fg;Q`ttg2)*jh-+sJ-Xk5C{B7tX6zm@dr3B@WrV>uufEH z6eJ@^wYWi+07Ivs^3E4TY_;9n$h16{=ZYjxKuyjX#<+6{n6l*!YcFHAiq(EGGAE{j zDCiu*JxBbdx85K%bP-sn4<#luIRfaof;RH6V4qhmf&AwOeKy#2JPV01PSkjUW{9tj zvCUjI)Y4D27(wxL(0*JraoSDvW>hk7!Q)=;Z%+B?EOr!5uFdk2Mhr^er2ebso!}rg z=q&zGb&XuI=hUTn2M9Fh;(f}HIPXgd&UW89+q;bEt6m?0s-CVy`+9h{M)B6ykMrv;@(o90EBWfgb_Nk4z6XKY+_tQ`;PSv&O~$BXDt4_U+%0)Z{zNCqAmKSu?Xgd}h27g71A>HTM6`iC5T)$V z`bdb&+c$5nNr+V1@JjB!wT}n&ooKrXUkgK>B{V94k&yVwWmuZK1=A~=kPoZ%`|*XY zaAURgBRl{uPM`mHa+uud{G8vYOZ&YKtGmUAJ`WO8lWq%_7jXoqt4uM03-8kJV^snm z{&&pr5=O`Rp?gRbdrY7pA#q_q*Ro?jkFe&&wejA-gZ!5vZ=GmcediO+Awh~!%d)92bHaCboNM#CCZGdc&bre$ln#11tW8x& zR|QearMoegw5T8d{4=3^5QL(RO1fogoX*A=8t&!*;;|Lk!5gxx;Zri#CaBBUI`auPBO^0v(;j`S%E`@8!{y=5PIqj!qAS5~WR!aq zZk7STudEF?9T&VgG2asnTA)8W|OF;RaZ6T@5^5zGIO1+H}~0yA>^d} zQbRVU8F&w08l0wM&d~@AqR3`S()H#KNsT!8lz~9KqP}sX*;uYl6v1r?dBcGz!>Ra@ zmhyq3LND^RlVwO1mxoPOR&B@IwQ6c7+0em_in45!vW|BOOd7pm%?>wXAXq`8e76ipRq`#}(-b6>6m_zf z)OK@>P%mV)dIh1xDB)4ZHO>g#Qukne-g8OEV??ks6(!AF9(B0!gx7tOUZ!<#M zNru`8jP~^1gSsFPL!Q&#Nm#U&SW$2)FZ?e26GZ9os86qOjt35|-q!Uvew};wD>%Ln z@bbrprx}g8T57ba%{x<)S&gTg(?gPzeI2}%-Ln{Ws>Y?#CqPI&%>0}tvh_h>6z%m2 z>I!wGjUiB5<;P>72Wqe1EHxe-44N*xWb{P-987bZeOIlm zX3f(tt|5G-V5W5Rcw_tq3Wwi_9km;jn70XF9Cv39EKm~A+bEiWg_7Oe@jl^sCu&Rc zQ&{-82oAeO!!pmIF0_rJjAxoqWS@WTyu)Deis7Q)PO5*qI!lb}+H4Uksi~l8epaGL z#TGcA>J=}FvPPryVNolU-OYTd6Hs(tNFu6O-)z)&=pq|^G*jerroFamlU*8gD1gw9 zq6+uiFrh{ILg7jw9sX8t zq+RvXPkIcXePO)@kY1xry6{AWEiFtISn!dfICu5ouX8#6ufVbmFWzt1C)D!5qz!pa z1T0rg#AU2J+nM-#H24EFa+n4QA>Y9!TF zaAfO5XzvC$rfE##ie?0w#TPEUO%CtejLRGg2@g_kHSKJE)Ev8w32gRh0uhGmyAQbO+?^%w2QV)Tyd88=CVMoaYeH@IAT;;|C9BCf`%)jD zK3SdkPnrzSa=!IY!!zoB5IM+j_8``{B))r9yQ+v=a2!YIuxS_}DK_qyG=f={c~gux z*`Nee@^RP74)~0Kfqe--$@(j%ZD**lm4It?^%47RO}TfT+#z`6 zq%$#RDH3^%+1_2(d26PID#pwQjV-&0nr#t6P}}ep20QU9n7cC6%Ppq6*eIx!^Yikx8$t*MVy%8J_i^%oi3J42(bFA zvUcnIMTnea;_?%hf%ngC=cleocDgbP6H2}t+|Xm;Mb8g+3S$9y)iIt2{fC!9u(i;CPzVOK&| zOfJ-*-Tj8lo7s{WYBYd7HfNw-5Mgq@xI8z?qz#FyZVd%Z0YB@Tt7InStNL^p{z`#$6&m5jo=kv zO`8>;1Q3EkdfRoWRcpd1f;VSxoPv6!Z4zThva|mtF=6>oxpOV57#1qty0gUDKA_WN zJmjU;j`Uu}-hF4vQnOaJGxRo&CozT;?V8ZG7ZJ z`-Qzfl#q3+a%7McAKrbZGckJ5x~;L@@1SW|t8K}-8`rDSoh|s$MrA*o4q`|HRIWiV z_u2>eX}XmVYMJV(07oVX8`GP1;n(5*aAC-PMs|MiGpZw~Cxu(+TGC5h&A+%??u#}= zn+SpC2%i;fYV_-2-tLS|X>|JVZ4%811kQ@-frv)v5R9Fg-h|s=UHWs|83Jgl9)yX|hsjRI1yXvQCDjA45{o zen(GY*c9G9Z!@0Qyzk#oN=S$t7*- z+hqU?xM=o35`(M#tLrd9N4s)Y%$ATiquDF?w;TnS&(#KZ%1WM+5qk&sQXcU0-n4jD z@d$DWl!<5=i5H86g2T(l?_8`CowYru0USbOh&b^=vR*;%Q%p=I?gxt>{}+kmU=nex z{>XN8&Zyipuu3%}C{M>a;ZzIg4xqEV`(yPqwEkc*H%I9mdc*mj)Ep7I4@x~YbjM!| zX1ITAfIIA9AGq3!ohb>-7jM+rX~dM7UY69me2z-52w1|`+`3;wXg>JD20$HJCAI!P z9Z#Bx2qzJ@AeL0L>+G?dk_Luv%Ex9AGAS>3O4R-p+2;? z?q>|G@2z~H}In2j-i>Al4MK>|n*4@k0weoVd$6>RvOWgbX6`2;2 z;XkyHS4}joUUOD2@@?|uk-7EGBtzqVyjyk>5*OY-4yhNHOP83!wu8+)p zo7vTS&g{cPTdS)fhk*yu#ZGb2-sa;_YIuyH{_}}jp+x8faDy{NqwfGzE{t2~%Zi#^ zbsVlno%jvxM}U4B(o(jM<%VL_utuc0eJV+PQZqmej=LaxfDvhmj+h?Ik4}nMY38g$ zR!@RE!siP>0l0CG^0_yo;GMGM@#kj|=*D}(I)K!e5u0wCVePo%^4;tNs5R?jp!wrZ z>3?E}WMj6@ek?S7dh+2e=<8}9|MY?~)Bt-2RR3K8)qe`oEnhky^d4zD$-BS-nU%i+ z7N)}+MiYh2q;9#~?$IVh3#C7Nv+qWBt@3RW6Mg(Atx$Nt`-QR}AXece$N>Xc01jZ2 zFGf5wxO7=^Jr^IN>D&;(!h~PwX+)wt2^R}K20H{m{reBy4`3n>zukVi6B1N+mam?Z`+2bQHM zv0A4Wb1r>Xp1?l)e*zB!F;h);*Q^MuyoS~R9`?xbuA^;>U;qs!SQPMi#NU)RgY~lh!U^@JgI!0_ec<|Mnc9RHM%(3FX3VY zr{!80O$@E$(tNU>C98l?<$TYB;nz8ix9&Z_&qhZbkuy_njefE+yUt~Uyypq3Wg87O zd@$E@h;lic{sQXcT%dvRI0RhK8owDr6O<-dWW?XX=R2yN3o2`O+Zi}|Jx(87sGnf6 z<60ZDe6W7w)T^WonvX7X54`|Z0-omM>UU~FJnG(S!OQl{Eka`r7Kub?1WDV#;MUZh zQ3VXIkp72fg5zcFD~Qw7#vj;0M*0z0 z(<^j-Js)}m#eltSXS{L`3BXoX13*v5291i8p@S12%;ZHK$pR?H%hhj-SpIbf-S3S| z+p9nwif*GaUFsFYyA;i)zkPaafI==K3p zodD}0r-&3T1?l#UWK%T(2VFLF$Whyi>1TZ-aQa$dcTXgMFLgklFXbC@ERlh9dzw)c zqXgU9rDi4Yc`C6xDWkk~JQIs%X~PQh3%ZEMHKDd=_8$yy&EIdBPV|@7JHDV=fI(8+(a{C|F}`<{ zJPYcM4LePDA@?E7^vToRKrPMl7nB-wnQx4j&GRRl$=f?04NBK9@>Q&Dj^m_26o(gZ zuzXPo*?n>EV5zYCXhw>)ecSJN^yjB+igX2giI(GQ>5MB}Tmf9y3@r zYh0g6GhZGQt_WdXXno7VES}B3l3t5xYQFL!@|I<{!;Sr*)_Ple=0lljX$W)?xX>Rk zAv5a`$ne(zDUQmA6s0Rpb_JZCSK%ofXg{`;lLHXD$}Jbe*Unn-!iwK5!wN1RF;d-^ zY!y%gK=p^_NJqnamF`79S?8n3;$yH@UCYTk{#grc5uOklp!Ai`+i$m@o0({LeVFHo z@NB4wGsXoIp&ib-o+1{%vREIY+({gQ?BtjnNA8SA)T}xu73xm)OQ@Npz3F@V1J;YZ zRd~W|qs?PA@u{2dJvxQ2 zIf4RRvF)c;?~KYL7JH5tKzCQ|Vy}rer=a?y-JC6TeFT5t(YW%|b+_ZynzdD6A>wmQ zm{0bTPdvA2vTfBgp-^Oax7;0Zoi;V8tQU#_44i!+nn2;(aR_jg@)WD6j>(rqy_E7( z8(U-ZlzefCjw<^?He;WIH$D%8mHllPBJn#P&etGExioWnG?o|L_G(Po?+n z>MB4MqvN0zGu*#}W5br2P?){m)K31@bg3r1&x-k75 zmm-B2qH9{E*7@Vs{MR(ZsKO7-1Ny=BVWt_Z7xy5cm$W@%{;Uu+vJ}-s$x^R^ZMo78T<+yb`mn z=6_XMS}@9AJAm~a07HJ;v6hN#={ymlz|wAW-A`nbhhROGiLQp4_i=1Umm>iH)hh(Y z?zN=T3beaZDO@~^CsYCD^f!rJTl>>5Qnmxis^1Y_f4PmjZVShFuH;v7Mf?SU5*mZ# z%{lAagUUTj3jGXwVhm47PN>?i+s+b8)25sx|4$wB_ zKu3@;-Az&!**{}2ChuC4sYY=P=-0RBNtbyamIYk@A>6RZ4 zR5!t9{kLU2_8#br){o@?j@AFXp-kvG&nR&F)iL|;M@oQw>G+%H!v7EfE`}mFk^kT1 z{mp5uUj3TzUox5i+bCz-5%Ls2mjWIdcz^;exvBK@e-}d)ODN}2G+qL{{hxQN2xYv_ z^x6L}nMgoJbJ0Nbf3^ft+d<%1c{-?nYncCsUM2uDu^NNb1c30{1ZaJ5qE>%J6XIRJ zuk&UGAQM@Q{q4&l{#jKAD4h{FS916Fv49?$lK{T@%aH#|1#U(Qc=m_&e^~!V>cP$U zqi}$x_1 zRmzgR$H-tf7u_adWp<3rVm5Llkt*Q*0|%fHKgsbz3JSad?qcHv@B zgA{}p6&l@j*t?MRtUv69r$LSuXvbD**vT$qh%klBottkY#7C*0aNJaIx+*!xt7gc5 zLc4f;eKIX$v{_}t?^U(21U~ZHm=uq(N6fmcJF`-Z+rn0;Tk)7H#P*b-{h2vra%L`> z-F3Jb7f_Yi7Wpv^&q&JaT_(Kt`NtAeWg7DF{(6U%uIJu4`o?^y85y0L~)cq^f2bwkZmJ#WlG)P#e{66!WuAYQ`dos5dFG2JP3^*%jSKzV@G9W zub5I=w;`srw_)PRWMI|hnZ+_?wQsN?lNC`qd!f>Hv{z;>JF6^HW5m?cK0CqE(1e9Q zara8Z|HIz5|3kUHZI`I5RBP2LLTE#jO_XhR)haPIF_ryD(3ror%D^ZmZh^L*aF;Qi(Ktxw8*-`9O#*Lj}Dc^t=i z+4m@Vs}EJvERwj4{Iqp&T=h%cBi;W9w*M1PPRn*mcch)dLW;al{7xltJSn*bLfVy# zV)g$HEp(|mxk(|&%EDVfk}Gy7YHZnH>Nu9z~prm)vE78a8n z0G$x!95~$mEt+hC`>FIQt)&v-pyij?*Q{OJ<6#T^*|y%r)|qYaNq_W zYd^j}^okax!0HO5#AKHj)fd85ef>A450(d&?TkIRF8!bHNb5t2>K>78(^(2D&_niIX2g}c8 zCA4=|GcvV5?a}*p;tV&gCL8gHKT+S}lAZ!q@h6 z>X}_*z8?S)W99;Tl^e1!3-6&OTpKdVzqz7OQ$^Pcj?G<)W56FNv2t>&W;ZA`e>5y=r&u9j|zODI}!#Pb=znG_5T1hBsKpyLhf#z-i5$VQP zomV@g?);0I-3*hwzH4KP@3n<=N)u7@R;I{@wGo7Wo(;TtoALVU9N``2`?>K`6*psZ z{y6y~@`c7k^)j)s3mt{q7CS9R$tkvPA+h~X~ndi|~V05a~xc@>ypcdry;aG^| z(fwGipT@_iUtJO`lTR&EP6D};2ICEPy1Dg|D@idWe)**-kU62oshBOkq7b(eIHUmF z+QsXYf$Q7s#euc@;_ldrKeEEoKW29{qlMrrE#G&mf#M)C6&wdZzTDwXm6#*9R|@-^ zxgtCI0&tw#&`wP_C;qe+^FU$gwcy|&+B|yD<)_(alU&tL&l$J0e@iXR|0DF`BTGAp zokvQG4GePd^RIad9C16wsk4`x{*f<=y~=t$KUR!NL+&97jFIA z`vot1?X2FD;MJ5}4HGSk5rlP90=q@qSTWmcyk%zEt{-SC6d{1&64400|MfK2YHbNg z1E3VGoFbQ(et$OWa|*9VcfUg5y#XP^D#HMR!Pwu*Gte%K44+7v!gjVp#*lObN$xmNJ zi?7zOO77)C0P!8Z7HnZrfL3pv5NSu`aUoMf%+=EAAwfsqY0^DX$*J`fk5O`fiLt#p zlop`ub>0+^IRAd+egk|K^ZpXUUC8nkWgDXlotx+DrWbS~<~Sww%7Db3yfWTdgL}nK z6&!mask+v0A?ml_`y7q(Y^W*wynH)W3)sxWM(H$6JX_?wNr?f{`ODQkfSYnBcIA%^ ze2ftPq%G<4aORvG>v{vJDdxd==+~1MlUF*l`rib&xCSng+<5x+PgZshXujTM)l*x& z)TF8&{QZxH`9G?|x-(J(uiyRf>N*V^uS?2S^xopOtbFID$;YgXQ3mi8SaC}Zqu4B3 zEW$qj9aKzT+(q7~c@_<#FfXU|mgKTom*Dusvkhd?8}4A=5ev4-rLBn*Tlvb)(vBH_RZ(7rWO0e{sj;93IrWrmEZffg&w$B zmGLAsnwT!=P&(I4zCLzRWpMx9j3@DdWA})v%Bb#(-^6xPxEvv{q)X*qD}?SyzTDPf z_ekSptdvyMM9%3?&#}JRawpHGVi$fV9VJwx!t++DI$iE)I0(M|V?MIE8FhbH^98>^ zQN*|zhs*PWNnC;sGCUBe`(jH0^V`3)%6i)O3i4bwnZ zmF*;G2Q|2&nMr#)50Re1Wa%1e>t?_UCG|tUVYfC#A76J6IOeO78f=7DZVRvunmo5wY(nemo@MKRx;kTTCLiaV?2PyUeBZo=l-t|m}-#+D74j1$3 zncGNjiZRP$M`iObY<)4Y$6BxxnW+1oV`aFasKEQ4`rQh@Tva5~o6mF*+`<4$^y5vL zLB^M+nW>y8MRJiqx<)PdLF?C8w4c6ai{G!E(irgBbS}*>Lv6qu^+3;pu*;s(l=nIdx~k*g;lnQyTt$N`%?Dd z{IgLm!?mztENRu_R8!H)_%)?Q&nu*TC#yX6Koi3zLu`zl;+fn0-zOHc07sMN;~IV+ zHh!U2iM(cd035RR!TUUo7jXI$S3FA_kLMoDgsE8kh3<~7Zt+^+uInXVMc2+^^0*-_ z5tp}kV`+T)AU0?o6@!IyIyd~?Lyw&xYTHY{UG4uqt7;)_K@agX9K%vcGC;K(Cs;M6 z11f|{urEkFAqj31Nm5liG%h?344(Y*ci6U&dC}{5S}j);=d|Y%&vMY&WYFtA)%lfwQwd}R zN9?7)csLX^I~!lWaPMrpQv@?)VnL%IohAABK=+4*IeU#1D+%bHnROS5{;gm2FOjzLjSO2VfPok;a)88 zA-x>*Qf$-V2-eS0K4?HC)yv8FJaYfmtmd>ck)Q} zy8<0FxDoVzGvQpX#Y&UMkqb3B0_hUF$rzwB!8$z;efu0EumgwDiPUX(hl9ce&x_vW zjwh9_-s;xU2ZYb|!O}BD>4X-4vV|3QG`azHP~}S*;4w8K>!0QL_{S~AUK9v_ZLMsJ ze?wI|SFTucclgag{-z0e=m26UJEzD!RVL&_Peq`nM{VTHVr>ggLH`lC^)dlhK2*kCzA2ub5O6Z7OIc>dLoL` zFAH@jgR7CB!loZFJ^0HxCc^H5fx0O8Ps8Oj$Jmxn=>%02dI;nXHW~x61wwmaYZU;acrC=nD zuJy}|+{ualO7?XiX6f=!abctzD zh8B}I-h*qaCmBBYZHRslD4e&p>La#%UO)w+PkBF=C#I?o-9BAmy+cy=*Vo(OMca00 zKQgl4UI(>&>bvcQgzYu(gVenmDhf{9HWxWvf)eTjz%e#HXO%@jdN%guE3-&iNe@?Y zPxPoZ_c%N`DN9FZ`n|hL^d^wVZbkKUHleHzy0dtF$nZLUt=7sc1~Oe1eG65dOullv z)KYY$tFAzBRfKv8Ik&yNk~%eNfCDhPr%GNAVO*ie$$r~yBTZ4${(8pr1nLHYqTf7# zSSYv3SV#mrUI%R9^^uAAk^t?e?YA!$cMkovbzpn)==~CuDWa^qVBSiOrJ4&Jy9C#3 z+I|L^g~oRbtnoL1$I{1>Jn|=4IV>}D-;V42(0<9li?`=Iji7l6#a_2RN$o+ZRGf=&>&u@AMpFQkhScJ2YU=Oc@$%m$FJ<|fDIYnT#_^j zZD*ie4jR4urL#u6zn~vTeaL!y8~WZW;NX#>Va50WDtq`WKAMVvvkIfOr}O%Us*0-) z=jZGy8aWANWH~%)6@ipsbi&-?Zc;qA+UVvpNYnj?cJ0ht5{k$#ug{;gA9gUy4Smp9 zWtx6qBz?8&3zv|X(20NEn z@L~ZU-45kGiK2)AlA%BBj&CiK>dRDK*W71e$P7CRq_^}#H;g0KzzD$; z|1#7?{d3CjmS21=buhWWYZcLFQ*poR`h4}G#pbky_V7O@lKYOG9mM2E+0Vf(4#{(f zAy5B4eKPjRo}B*KOuq@m3`At0JPqE1zs0Zf&7Pq@kO3%ACh)_{Ow)}ql<@6jO^hT= z;S^SiZCKdfi{e7tL*B*NXz(fm;S+NE=|>ph0l_|+Nqy=z!SG_ltKOh!UbeTR$Zj%K z!Z`lbnkJ33nKiD!50C%H@xH``gX(5x^uuT94Yg*<^x4XIempCufG$WU*6gt{>y=i^ zI4|Djo?x3Z#_A2B)gD93+<_k64~d~~SMmuetV?d=ZIzPzYo2!T_X?uj#}Us()pgGe zGGa7HxS)JqPj9i3VSC)IE^20Lu4ExPXaG@SaoNvYhaZka7wYH)*YF&Y50NPB={p?! zX}`b~2mGBDKbe#_HL`KM4x2n=T;9vlws(~)p!}I!m28f|+;qx!|Go3GmTY@ezwRM| zGHe?qEDI|8rNpAPMD8BSx~ThtzBwAMIw9w9ggR)JlCG)y3IhM6i3?kAg0-A~>=f68 zz-p_6KWL$|v(&y^RxyM?9@j9N@Vseqe*xE+1L$AhcMY zuZ}Nr$TQbV4_h!+qmP7BYW@9&3zI)X*-Ux7cVte^$l`YjluL(U0_upm$P!OXi?*}@ zHz1}&I+9yDPrf|q0TwY#Dj?=iZ0O~zY+3^w7+hq^2PamlvI+L*-h zIug2BOx=Ol9PcQlzM#LK__8LCQqoE(jr}Nj>YU=zr%278N45$U`96`E0|P9S-&xMfKz2gIAa)wfP6T)NP%&r@Vj2#0%YG^@%4!zxw~i+ybKd!-vi#z3?bW zF?o0F`5s|0=|aaGUge*H$I{~b&IMP^TZWuwf7Ug$)z6u&t4wUI1%~sC3GLp~tXEMx zxmY8z&zd^3NigkeC#rU+-+1givKQYFUZxRKj^s2rNJVzc*57CFKU7DF(?}33nMuqq z*KcdL>W2RY?!YVU>azU~pT)|jaWb;jr;$d7e$OOFLVmq-x^!wIla};)YPq<@ftV9A zANkUT5+yEv4wt~&3b#sb`mW?$n__%ds+NlyEgC4cuWBcFVU3)P*z!` z(7nX#?pXp?sZ00(7uN0-?Ll8fOaIlZA!8uFudq= z<%;MCl(B!u$#(wSp2^YabL|VOD-p%*KbUWk(*b2oNWo76ztD-Bvh*vp=8dok8wrFx;RJCW78 zcrTR2HJb5=RB)Mllqc)W^p7zGR~SuW8oeG?XaR_fgV{wgP#yaK&*s6us<+3+wz{O` zhvwiyh|lu2m*~TCm3U}WQgqA$!5@Cwsxdcda7vz6*Cewp&uqvrouk@OTLV3l-79bI zm#7B{p%6dJ%D%bk!d+HI%b8ucoo>>}GS(0`ga=NndPgh=Pfty2Ku)Kw?SHcGrLzY) z(%P+vX8q)N5~-GW#VKy|d@`2NUlmnE4l<5~O0a!v3hTeRaQE)zZb4^*=kB}L=dkq3 zOq z=h4Q!^tyS7-Vcf&DV4ah?m>Y?u(MgAGCCE0658#{{LS@rstXo6>&;E_Hk%Xad(`o{ zOvQuZ&Ij|5wDh7vtlg=H_H;eJYgy|S5p&*SBmjDWrRvH!&>huOo1o(3CE z0lr7F4_~?i+B0Hv+Rq3karcblUj>GeAS{gCm@ih?>YMy{&@A*3e9>9KiB!3EZ*zon zrK)Gdd+3qvgYiADq?KwxTHOHCjrtB3n*oz2YftA6D~gZfYGsv2&u=t^^; zR2tyeo{}8Y5J$+kG>PzsMYnpY>Sd+@ftYn>rHjlYMscH6)W#~T* zg21h97NL{V_4bk2pS@Ko3}d4qC9t%l_mO z9=pT(TL#{ZYgq`p9Of*_Ob??+x8$8bwsiR^}@ z-ufX(Tti^-M-|cmeU1m@H8{FWy#`2u8q^9wM{su4xLLC6=rqjh7ORrlSEYq7z1dgJ z*bb@FWH3#GQ%Ky3SGE7V&Md8kRaul=e$*=VydCXrZfeX*?7pJC8Q)g66$fVFEN!IZD&7hWLB(N^!?hc0sp^-M`jseHuKsbCb*--tNa z@D6VKs5Jo8N29BoMD~ase^Tz_O0j~l&r*Z2ufR{ULvI+1jCL*LNkO%y`ZVmbsAddXVz~z~MKcbVJA2 zW9AYjy(|ssNs891g*D@!c$e7%99~$xpCif)sEPpv%&k6MwTdgZ=j1%sHx;ZS9&CSh8BjzS)>_)`i8))e zrrQMV9&w8BUI#Z$FbBzL;kjHQRA$=ffL*Eh z(pnDDYw5wU?tPbKHkVepJqf$Eg$h%+n23aZV@?-nf9?* zAC5lr?k>QsHA+?iWG6%Ff4H4PohzjbfuR+t}a{%*Z?N5^QG5hwWuB^ei*h0d#>6>IJP{rJo z>_yqCN_E5i;bvMXn=c(+;mqF}*5x;)+_(J<6yhD=p*wDLUr2BLaU~x)MM=8oD|fQh zv5>i9yEMq+laU>dAC%{Hp@A37zbsN~@dKZ&*-8$Nt zn`)M?TP%;h_HfUVoQUU+TQ|UCw-I~H^GDJWl8a(% zi?VP*z^%HKNx8FMfqjWStzVtbsJYc(&l*_Tyf(&u5NPjwBXfgf!Kl zm*Xj|#zezKD{DLH(PYcO4qoOk?HIjq4XX$bNG6wyGg)vAk| z4@ZpCO|CNxYgJ>hyWd^7|9Eeal7&V=Pr_Bs9>mMMC0Va1$1S0enM~TH>@i^@EUCL3{of+q23nlnTHr7UaS1Q9G=3&e+1%xLY z*f5<)r6SEIK!rE|O!sC&CGR3CXF6wG1uFCmi|&nS&%3Amk?Zj?Zs+I7Km`i$9q#P+hH?JE1Ux!{uxp1r z%BRB`MNvht5BTX?C7UNPL#aBPd3KZ&`7ZdRKS~HSeXhOMGdqQ^(TZa|Nt^rnL=m(s zzqSwUAr{TK&v~XyXSN2J{XUJ(1u4ny-$5<=yY%$sJu(yVy3Q|)J359oiW9!4Z6giT zg(3V1C4rXyge3=8T=e=t)u?+v8oVlH46S6$+yK5F9bkg-YB5(@h0)wkdl4=g-z=KX zJO8an;%_4LlsMDgE>Zr=24GP|BWKCR#_=<59v0s!1w0fvD{R;$IR8e>V$LlbGso!A ziWb`g(-g7phv`gdRRWC9)lbgpzO}6bc5N<~poZ0`w)nE`!kb)ePGZj)3ND#*zRyq9%V;KKl5mV+ zJDC3tKMvyCyl>`dUM|^46SAb(D=>@|`zC)HG{gAt$x>$xKf&97+({Q`4;UUrWP4A- z=N_l7;8uXk6bx-zGI6%0PbKREHCVJP0OTEKwf*O!S+61tGLVbzg?7@pL*EC?FH{OY zeD)KpfA~kIW?3KGTl%jY?vJk(1Z3F0k2@ZQadO@7F@Wk9=ou^{f%O)(_v`{L=fnY1 zx#s>m+DmIuZVvc>j@b-E`_ceP!}sKF9vHP}I#ZcmQ;m~>vJnhm(^_vGeBatQ5c^7h z{U=RpfNKuUgGIQ-#1*Zg$NS*2Un+wR7b;E51?I)s1YS)(`|e`;RZ`X)kKJOaR!hzB zm#Kokj&8lDWCT$PB|O8f*upi}TkQP;MPYa`A%Ue<8M$6%AsbZjWTJn7MD%Q9;}i33F?%Lv=%N7v4J&mFaBKTk&avNxvCOzv7! z`uVUiTNMGAOrWEbe}wXJyzHI6BAqkiG4yRI*IP-jAsC7#`0Piv1Z>wiLR=7GoNFPE zPRKG;FU#-dCtQsOnezG2ucbON(AaczHqlmx|DKZFP?*f`xrL)ArJzOQAj>Sc@S{(l zG=OlptZVp^F1KtnFw)vjU}jzDDC+B7f-0{=Ikh=0YU*F~CT{s=OfNaNtPT37we!g1 zx##k9EwkPY1n z`ErjfM0X+ImAt58$u7hNq!u9jYhlJ#Ma|M`M+2@2x26FEdZm)S$dOgZ0_IBKh}f}s z{)&=p=VvTCexrlHO&9o(@-0akS|)PEy8&dqwh_e3Yu8 zMuD!ys&VsOp?Go#LNYFoDLK*a9{lsWTf%66VOLAvPBOA2%JF#^WQ=#7QdkxSs;7na zY9A^rr=`z;OaSv4005{Ry>fTH=~GQETXd^89#T*E2hBHL8xU?B5JG^b`vOQd7z_S{ zgRy;2U3izeOOY0zLmrqQsOBF8O9Ku3 zjq|1le>DjWE-IrAtr#cuR<5}9epGm&)lYor#@v9is#kXwg1TfwQ4CyKP7X;FL`;o@ z{nO`$4~s(0hqA*<=?t{FL+}aixRHM1s|bgHu-@fwXnXlORdE(H(FnU0dOY|DKp#_s zPU5bL!j{TUEVpM*rOmoL<2^1vA>*!aUHVr|)UgrT&TjqrKFcgdJQQJcsr>KaDxH@t zBy1hRLaW%X9EmeO=tXuGjH~nhjSa_28qdAHN6A{8Qf!&YG50vNP;ZKJQX&{5^hP%a z)>zsTPdEf*g;^{H)XkrYh}d07&widt?BAx&FJE_76oJD|)6?7wvrs!`Lt`q@e0|^W z0@GBJ;#E1CJ=J_P`!7fL!~%<3o0$ce#o_=`-?^adRxOtz_sHmf>28J2CmnT%^NxoX zE4&K8wBG`H#RHjZ-KRv7#(*4AOQ>oc7q=1^yaG2j&8L5X~AU>>I9of-fn(<%7K`q7D&FD;T#fh7*RTGr3N*k zUx~XRF3qcXuL-}iFF4z&H)ewrqe#4{MM;vDk?Px_nMc^5oSTP}XhTf^uBb%Dp~OWK z3;LSq5dZgaOHWJQWrk!HeJK-dDv%u=@FchpvOxh(_s6F z`wFXqI+m%Mr!9_LsEZ057tes`)~ZWvg-F0jQ$tPYe6WC^mW*e;nF@^o@xfh!u#@Hd z$8uN+O7_+f`&W>v@ie3jny!fFxs-v~TN_L8vCu`z#7>X_c!)DHr>Z_;mL=g4PKNik z1$WtcYj<1Hz=f7?6$o|e_MqYLa>l#oE~^tRM~dYC^1WMlDn7RLRxDv!JNBQ^?(V%% z((J!mqQOzIISR{}zChZ>M$UNYLmD-lTp2mnNt?SRl{ZVT}gMxUdt=(fEvD$4vYe}EsbT_|5~uR zJ!kX*;-ZW}RptU)ABivt>4zI)b{ITZrR7uFZ7Zx_9F>DQF0a!N`+z8~! zM-_Mj>#}O&BIcE1>H8Z3pfa7V;du-vfRx|EeNrbk&vxtKf-Mv9JN`=<(~6Q{*S^gA z(CaYnlM@q`;-Yq6kG7JR<%QQK+Vo}XPp{zYQdPZD#ovC#DG(IQ7WN|kM}`wzXes^h z@h4@eT!c((@SUM<<^?XQQL;J@>Z&7$zdRTsUY{Dn!N30d-v0fE(c(w=#Bp}AGw@#fk3J`s@tZoArapaG-#wc) z!go)1lm|a~FSRoHUf&kf@c;90;1|UI{^iMZYNOqo|CR_X-n4Xhh8g3{fk>kP9te5~ z_dow>==mf3lg0Z!>cHiv5uAuagNxVY{_Dd3cg=2##Yl(s8UL^5=zstHf4#T=U7!Da zga7;e{Ac6*|Nb5_&3@TnJH(8@D-Yw@-j%N&hyC2e2bL~WiZ-)Irxn~c53zqRn zA;u$9Oz}?_Yds+3z@y;aM;lwcOf8-%uP-i)3nr837V#{vjn7^sWlX^3+cKS*03)Pg z%r_wt;$(EXiSQ2$FdRsqEq=D`hk=&LXtR+Be^Pi#*F-taNl zpVOcbEgqm6FSZYek+}n=XzjN?CA*21%XZc&FN#^oS2o*d*%E9i>DjlQm=+UxV9d`e zFax^j{0e|%kQt0PWPM@+{^fZy#g*CbtiwV1WQSy_-PEu!W^C3HkYNh2}t|=tiehZy|je2$(nec^xx(0FzmLy8)S7zQa6Y zXBs4vQ$kfW!xX;43MpQjp-v|2ft;oDwh<}o(N*6L?uiBvS)j?S%|-5J;Q7z2Hj*71 z(P*}RSODog$gj+!dVidH;b1;I?n2wTRBm^8P%N}iugbpu! zT);#uo~tk$qy!k(mG|#79tl1#$QPd0*)Bl>ru#-6(G5Z;T*sAWUEwe_di94GKQQG+ ze=>gW_#~r-gKB51a%Ad{tT)=9GBfU*o+;m)D_6oG6H8+jP=b-4UC|m_h28xLCk)x{ zaiwNTbu&nIfSBiyE3-30Z-CU#BSDpX=y|bKu#Zcs{pLl9wU_&b{fw!_)1?6yMt%I| zBK{O%>_s#e+fs~Ve#89b@-TGOAY1!opm)Z3r})FZS{l4u8i8cO?ecQnJ?{uunJF0N zK1#YQim96}X%JeS*+M-Uw-*=I&uGBHuHzI~16pP#=zW6mUyYEq?_{mwpq#w|?^Qo8 z_JU@AT1!0XvN^_gXg|xL;3kc7!VR$J-5QH)NV8sg3zZwV-ZT5cINaL#Fhp|!qBf=w zKVMzEM)$QV?T1Hu7NHxgT$6Ks#xT=;>y1~|SW5j?<6c@D$sRF?QQ>8(Qf{RXmdB@~U-4WT zOB7`Z=5*G-^jd!6md=X0^V|$PBDgUVHfF1fB~f6%i%skG%A7n^t_S|)g_0@-e(6^~ zKYo{0rWfJu1uZN__6OU%K9N!(xAsfo0td`EkEyv1=g`h5)yi5GUp7xEioO#(pBW9n zCuOfH_dY_=63@%R=mwIqd1jg@yE)(!$YnY=!==-wwNgmQdzoR-j&`J8%!A?JG2#~X z41cJxdKrv*IRe&(^2r9Na<<837Lg++NS@RfUWi?NE)S85*WJ&WWSDHD>!zR=9fQntK4fg;Apw;$&^yY zTTLi?wlYT&oxE%pyeFY_eQ9)|W3}x@mb((++8P*mwsSeFsgNApBh&~RQ^oj>GoR-c za3g5T2qQnCKi6Ev3XXeQ?yzpcSy_hw;51 zk!yad4(Q+}FpDiArP5?UzvWV2L$Z56chMvXt@yhXN#QKTE_`K4o39I){^uY4m2Jvo6Cg$L$^wz$~ zj^d5}PnnIoSuFGZfb(W2$PYW16CP{ASv51r@)>Pu)4MQOdYIhk`=B)1;nMTk${YRLd}5S5?_^5tZy6P#LvgpCkJ#>v7Wn(Y@VD|;;(!;#l0m2YRT|}@8xmj zn;3SvP$p$~NK?0jr#I&C+8mY=4W~Ic_ST<1d-(g&TpoEss97L-9^Uyp-YIWDH9!Gm zMUsJ6CNcF+zww8PEE_PimfzFj+WNfx$2|V*Z$Cyndf+^Ju(fk~mvq_8tRbT7;m2cp zOV^AiZ?SDyh}g<$4pU!ANdb#&FF(J#8fIoTS_ZuEdnXtZ!YJDIoB}3lhd~A zN8=A=UV!OjR}Vut_iNK^vCmuh%^Abv6)RV=+T(&HZeo<@u42h1 zCG9ta{TDuq{U+RhmE#Z{=WflcbPr#{aVF|ewGY-`;@ChF^r<5!5Du0A@`PIaTd78P|3*)4f^#`CWQzn6R(N7=xd z#m>{kl0Il0KmV%Vs;IF0R^Sa~cjl1Yfr1=_sV!|?A?`S6;Z5$A;G@dY4Owjj!Tr7U zT}ZQ(??=RC{_Pg9yVl1HpPLjp<~yef+q$*qu!T)VT8z6N+_rk#s?^P2!m8Ag7Sk;TZU?a8B>g z%zKV&VNqXazA=V)Q5(?`AM#kC{WhZy?I3jk7Z~%5-=~Vv-e#OpR5lFN0}ibfKCzXP z9cgtoo~O2tyS1MOuXFVbVB$<*!-mqBaZ!{WXmN1h+rmJ#B8lw?hSGFp98|+f{*OHD zs;e-pKQhIzGy8PtkEBqKaT}fF+6gzDXOUm{^Rr;#zDxL(m!g9du_eg3qsH7@sgtdDR51a*Kx$U`C6!MbU{Ab;I zYMknb{B^m){%>4KWm0kxly9B#A#*J8R}N8;$xluy-0zSEBtinSIW2@yGFGSKavP=0X>v4m3>Z{qnurpR{j^ORF_pXwLL?Rirp0m^GFY>GH{ z!b@C6{;US^Gf&G_|5x>i8$rH5=@Y*is*?lL)DQFbejE8$MqXvM-=k8y{xyzNE}*^( ze-50joofCN`yl`WWEf7xg7fis}O znK~5MZZ4BRf4`rZ)$lG8Own?z+9!T%V&}I!Y{)@X4bO>q!wAUf2q3+%UL(@2_a_rz zieXGj63pLWEV!q@+f^a9L6(&DJMa5VuEOfF&;z;{g`=Vf_mhVK$!~V_3xkv14+d<7 zJvam^h3}eYO<7SuR{qf@owG{Y#{H030zSm;5_X_DmUY(VqyzyqvKC1$T^*NGI>M3+ zF)h>^nCC^HL;O*PFBo!&n2RsjP?3GtN$X6yL-^|M-k>$nT;9{Z)-S=Yf5iTJ zw#iX>)67RLDH7uL6&xZ^G5v2%?hgv$3guY@S;g44ST)mUnV-P7VrXad9mg@AYuUSwlnx>8b^+jw7Y2vKcX&g(An~73U+2XK!5{H$B)u3)Bqixm$?Do$M=%!B zDmGyE%0ZX)eQ!;3>1SS#I9T_B3mG+oowFpE(tZ%!nl0hic1|gry3N&4PS`w1IGtkkdlA`? zMB<9zz=yt*pkcLk^bbhyG2<=bQip<)1EG5{^)%Qo+TuBgj^pdrQJkcRz?J&(pNiHI zkvsxFd#SY7y)^VhZpUpA7c;Rw4vC>tv~^!a7`}TW1m4^hO&~AYFJ=u)mp>m!(UHMA z3>q^1b?;i>u!KSxcAF!<<%(u?iH#k1`uPlB+$YBj@3P9B-|C&%>u7^ zX+*^zAq+G0{vo*dec#HvEnm*|T?S)A%~w}CPwa&!4Qq`<>bk%?o^71Z)>DFlk;#&xmi#UlMpV#%;2Zro8!tHsHe=LXA9O^_!H++GqbRvjF}pPIb{x@&9OBiHA|)^6*0+cj z+QGB{y{dOTC0Z>*X{6Y?oyYAnOxS^_nUig=CH>-bPMT*-sNz`neCMMgo;7J_%#J8D zLo(pL4MIh6o#o7ZlMw~KIliNM#k8gdhIcEMY}3iueBRgNqt8kySl4E~$E<%-B?-X5 z1|n0A%&PRL?vEoIw~5!-F_v{oW=CwbWHb0p7G@To3`C$WN$n-CzgMuptopxj&Jjae zc7xH;`7Ih$*51GE7y)K#a|^v)7Z`=TROM4cGqvIQtfX(B?F(Sk9XDghmBG)%1}+H{ zXtu@vPabcLQhd!@mPT$Art91)hEk`*DVxXUog8@8U@G=zpQoNSX)FyXsOu%eXnc!AmmMX&d$PA7PAiEKc-5oU*4svbvF* zSFo2QZQ3%~l-4@buDQQ$%#JbF;6VPBKIWI?;#MOJoW+X4kLm&#Yv!dO-)6ZXnqA z>?Z6yrf2*h!lGatm7^|HM!n~R{ew8Vdd>K!1PBC@|6stI+E+rFnI#X$_iUT zqi|~K?O9(l=lkDXz=e}d4J=Zg*DN~aqV3Io(YqDr#PZt+Z8aYI8(Kb3TJE&e5EOJE z@>F+cOkU-P9u|jAJZNBsd3DsgIq)BxID2U3D8!6(L)s}ttA&XY-xhz|kATkV1f5?5 z2uqZl66CR1%zRrV|1Gipsz{%FE`yXaHDEw6F2iga;Pez2Mtt92Pr+Ce#!WRKh>(?U zk>fpReov%%0{#1&RN2hjgQE$~kFAA;`c#@vW);@s)3+9H6YDK7Hfd?v;oyW#aK8DX z^OwJVM|+2=yU@Qvo-dkF+(yc9~SjxXXi!D)hzFw91&%$)HlgI zEb{=2k@DHYDcY1hrZE*ubnMlGIhjZD2>++O>;7x<>f4HFi;7xo>i|KEaRQMjd#Kf- zERi8A5Udm_G6FK#Uus9Kp+WWzUS`y zOZ$GFzu^7APmFNicTUdvp6~ZM*Y~W~`11;h!X z$$?QqzI-KUChtAO*^B%19Yeig=S$dVNdkS?$IPoXsp!J>dtU+(tO~cO{KNy2=`YSl zYZhCJ)8n-BS0A;VtvZhe3W{eD1@e)fhGq62duItNLy@ zk^t3&>a49O`sHGbXYp4{5E@my9L5aD!9UGHHwJrH8tQF+5aisTg+#Mkc#T}#WgUCp zZp?&lK*+FVn@#w&>wXm)wjx+`FCEA_1*kKFP1x%cf3joF47Wv^( zd4>;s|E?=Po9|+qOXXhPgTR2y>Po_)5BQY&wAXQ4+GUOtxsbl{JOe<)=nF9*6O zJG0qKFB?`Y3<_<~jq#|AJWW&NYqF$OxY82KthPJ6y!7+tr^?&qy{GTwN@Vq23i!zi zg}IRUc)s@i`?&|4D`w0YBTz%WR*Amsca2r^QVH4S%~rh~@9b#g`mKNK#5d%SspN5& zqO$LUT|(lfvE+s}serChIxaXPuoQ-N7HvJ0aLE{-nCm9WXj9&CdT^Z(w)7_}ulHU<-y}rFLao z$q~Z}H!sRPw(&WXogIVKwz6$K_l6c@MMZ87-Q_4U_5rRm(1 zHpXui*vCsM@7()RWmmSZ#RI)v@KbL>Ke&vTv(JR`&0wV$~9~WWVd~PB#M8`SJ)>5-t zyM*spT?+t9?Rte^k z>J=gwNvhux30RzD+T~V1ja^dFPmA36lVjV(OXvaX5=V9IB4t}_KbD4x1#B^Vqc7%M3%DA?C;L@UP2uGXNUt_HpZ zzU9Ojl-dil&U8L#bI4oNHaZn>m)CbDvbMQ+O>oSdDcBa(87Z&n8Jzet6Zhf|OF>r~ zebrJ%x1RkDu2e9>Rcy_n=PRhIs4hf95SlgM!f=+weJg=mm@<<;m;h0T$UxcwTNWVj z>z&TvL?3#mTQn*e)?&wVaKf}~E@b_-#~nV)kme8F11xY6_^9&hfXW52fBH~BQ&k_& z{&T5m_64VaR|THyns{ylGFHhOkS|xOi+{A>QA}JRFrsrTdP*NpRTagccg|~#uAP{7 zA{aM&A_OH%7zJxzv zgeOJ)KC(Rygm1WkO~~A~=T&`Yo?yDl`H|&wrRM1^EwawAM#EQ*=tS$3Ud%1MHYyXl zs_N(6TJK$38+=eM_zFjDnOYDB$Aq-;@*gaz8Q;5ry4)hfAiu~hSWmA`$%f3QMnNXg zKp(zy`LI!F;6ZiRZ{}v_<+>3bVpnEh;XXioyQBlOgl3#`qfQ^wopuD4Dd{ikfxeWk zIP^Rz$$}VlsuJMOr^j91`RXy6lFri#{lFU;dVawqQAo@wO4d!>%{Db(-yMtI2_Sm; zUy{fgZXozJE{83^zb20~9U7hekkN@EGaD=B?j})f$`jU)>WMuUlAI?<1HJMyM%m3SY;DC1&o(y`_cB;snttTMIvA+@>YVu$^zv8WLz&_ zGjW;h!S23tPaWCB(^7eHw{*-&2zBdAxZgDtN1Yzs*+#NutU-h}YK$+NIpuBiD+^C6 zBrqef4{ryIDB@ORw6=Ka5}soNPDFqB{ul(XoJoR-+QQd3ig?@Wikn9v)N->@HV_qh z@S`Lysp$xHfk~|(piAc;Q)#9U?ojJ}hc)FLR8#r>M$!jo{Kv(Us-p;TzwtCh)tG2A z-!FlzDf>@RbT}gIi#5uo2-MldZJ0ECgvy?w@(VrjWJp56fm5%G+d3GK18H6-`2tN2 zjljg2);g@iWx9t~`14%#{ul<%a_MJz1kb6RmldMI|5_clPsf?FBapU}8+REdfK(+> zao6y8BZe{5wv%G;`y;hYWm|Bz^R!Sl?}9yb<8=ss1wfrPCnT^%{+yk_o- z*jIeu^m4UbYrNLK?bcU;V+E~mhd&-PIqeRVM=)kaCKo=unm8rRn7GwRbwr;vurN=k z@}QL>a-n^uUTJ-H_xfxvmTGxrun#r{JewNr4npk2_5w)2W_VYdYs6mO{#XLj{nAfm zb}DKgxtEgR-<0&4D~$s^SEAy9Ffa1iPmHiKFNgzq6Q^WCO9T?7sj2uU5%DNavJ@K?_Ps06NZOQi z2^@@QgwtkdZz8n68-Mcg*edaeQ4sdCVVkC7$j#~eMoe!ABUlNcGPMF+;1V1MF_Kr> z`wqxF$1zipJq=QQ{9Q>ap6XxAlI*XoXx7K+YT1$t-5NlH^6=0?C*G#4ZP;4_cTZXo)Fq+ZPTgY{h|S?{FebXtHB78O;rwoWC|i>0=+wkpvzFO_s=fp z{R+f053>M!r1seF_iMKs3>hb-LH%+#0egp&ZSEK4l}l%F-xAd|JhR^?xmMw*D=Q%} zr*sY_FCSy|84yzxg93nmWK9sC=46HOM>9EW-u1x(B&)`E#}JFfPqbN|jb>=Ag?HuH z_E1gu7)u|F{eD~aUt6GY0{~j7^6}5MMODw*)}$1Uo`1j#^3%uaw3Z7x}^Zo}Tqecbc2xmm-qiJw8^yVi)3GQ^E!Uwf~3B4{u?}jL1mrx0L zh3W@vwgkE}>7PmlySpb{D1;zqNuKTUApWIEQpF-9cnKfoMjSG`HYMno)PPGTPId?a z*Lvoz^XWCk8n82jCK5{E?)g~M-xOGQqsXRXW%OI)fo6Y7J%_F@itdO9>L}PUxmCPw zx3qR4*(`!#UcX}b>pBIG6aAOKSO{ z#y47P)`rs17I426s8D)V;n$4lY2s?CWgRp;GmY?RG7s}HeK?asuPH%Q+5y(nyc-dZ zkDZLcM4SvLrV?%zCAAaV^Ed-urd-q@Vo&r;4I7QR`DvvAv^PhTNhwwZrnDkY6ghUf zk2Hg+i!s&G2*#a+CryKe>&02B!gO`}(;a)B!w4FZwCICyQVI(;@A+6Zq#B(|K)Z^XE!e{XpZK{CLK`tDJ(-X}?_A=d@)rtNt9Y z;G6**ZP%HTsGnqBC4qEKac$Vl@H;iYzDNu%qUv$V_Xuw*Tnms9dEcDqIvf@|d$ z(^VVPhw1(T*41SG_0Y7-2<84Qlt0@7HD;h+)?6A~SQ!rtP&xqXgI{llMECbKnnqoHavb+I3q6J`#)jlF8z^a0 zlhg?L8FbmhN%|uz%)t&C;^WZVhnL9V{HcrYTDMA~1FC+TOpBaa-7dwpCdirbJIXA; zZN9t=M5Dsq*dI2^qrY!qYM&-$VlMtl_w!puRH?i9@q5EiO4ykgxbA3W6@h?ewkSVN zcj0Idd|Ak;O!dw1Z!CKGF2Fwv|D_H?lYHo@oT=sr#jSS4yH$~6_%kwE>jd~3;p)Xy~zVk zZOVqd4-797O9vG>bDK74nOI-G=z`8G>8d%0v!!rHif>@ayl`_^&fUs(kMboulJk^D zItdxuf4htij;=$;l>|IDkk0`>rR2Xh{}W0mgy+$R&0l_G(nA^noo!8U{DY)0UD8)W zIN?PPa9Hze^0O?xqL~&A@&@Fw*xG>L;oZ^%c6VaTxuZE>FI((HPGyDfrY*k*aKdbl znb_R2Cp!io<8RTCpq#usa}ZWOUqPlkl9T2e>zBMn%H5Vsw6f!~!`M%W(_M|%%os)q zv*tMr+Ee$1Q*Ppw*nwt^6o!NV2!s$DV~Eq*$j-DgNbdc)(`XhO2Wvw}ngNM&PT-4t zB-C52a64#9{&!^Qj)X3P=0W<~p2aWBRtzVzy7_oQ{1QaNQD4mx z14;krm6rvJLPC_<>sNOvzcAlaX!wvmbqJ*69!7#|d$gzhO(QznoM?r==QUN75O(N{ z$=S|cwA-L!53XOY59HbrK;P-SkU4iUAbXCtbOq@e+uui?a@6Ts>|f$~Sw`8B?K3d9 zEhD00mHgNL39FrzxXd#Ue53Rqe$lXHR*8mwZofnkX-5~? z#hP~lsw$*9d-8pBK)T>|GtZq+xP_FIjeF~O@I>tCX%m5o|0rpBtv@FAL`NyYGr=N> z&$=XDp&Zve4oE0}t}3Pr*4JeJQUk<29H>a-joc`d2m^nk68iBFIN@z)tpnbw<)G>O z-hV9uyC#(KQJXihs=fBPstLgMU36z-^q8P zxK54*n$jY@c+&|kXr)SJ^Jl#_PNvXsfbsH@#&;8^YiJ^fL^2uKw4T z0|T!G#Q(Mb@dTSrU*y6QsKh&MJ^9azY*rcr!?^M28>7B4VgAdi-dLs^5oRMeZUo1T;J6VSH`1?v zq@Np^qw+?+zENsy6nGo8=|)+-0cLFAFdOLC25`55IsOB4g@EA=2x$XC+JKNYAf*3a e5K{a47lue<^5LhqDmQ^I>mRONuKNDwz5fA(uEA~q literal 0 HcmV?d00001 From 1239a78badaecc9d330cb4da3a4224c8670172a8 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 08:24:14 +0300 Subject: [PATCH 20/30] Update index.yaml --- courses/index.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/courses/index.yaml b/courses/index.yaml index 9504400..ed0cb5d 100644 --- a/courses/index.yaml +++ b/courses/index.yaml @@ -27,7 +27,7 @@ courses: featured: true logo: "/courses/logos/os-2025_logo.png" - - id: "ml-2025-spring" + - id: "mlb-2025-spring" file: "machine-learning-basics-2025.yaml" status: "archived" priority: 90 From f24f7e3242e5d92766a1dcf233ed40734b25f2b6 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 09:05:48 +0300 Subject: [PATCH 21/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 7390026..225f959 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -61,7 +61,7 @@ course: short-name: ДЗ1 taskid-max: 10 # ignore-task-id: True - # penalty-max: 6 + penalty-max: 6 score: patterns: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" @@ -88,6 +88,7 @@ course: github-prefix: fs-lab2 short-name: ДЗ2 taskid-max: 10 + penalty-max: 6 score: patterns: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" From 237bef2889278953c22ed0a8ad8f3b40b8c9344f Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Wed, 10 Dec 2025 14:12:11 +0300 Subject: [PATCH 22/30] Update machine-learning-2025.yaml --- courses/machine-learning-2025.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/courses/machine-learning-2025.yaml b/courses/machine-learning-2025.yaml index d6858fd..35ad451 100644 --- a/courses/machine-learning-2025.yaml +++ b/courses/machine-learning-2025.yaml @@ -122,7 +122,7 @@ course: ci: - workflows files: - - exercises.ipynb + - models_train.ipynb moss: language: python max-matches: 1000 @@ -141,7 +141,7 @@ course: ci: - workflows files: - - exercises.ipynb + - models_train.ipynb moss: language: python max-matches: 1000 From 09b1f49b099f924e3d2549da5c4fb66670ef3830 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 17:27:45 +0000 Subject: [PATCH 23/30] Add archived courses feature to frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added ability to view archived courses for students with academic debt. Frontend changes: - Added status parameter to fetchCourses() API call (active/archived/all) - Added course status toggle buttons above course list - Active courses (default) - shows currently running courses - Archived courses - shows past courses for debt resolution - All courses - shows both (admin only) - Status selection persists across course list updates UI implementation: - ButtonGroup with 3 status options (active/archived/all) - Highlighted active selection with colors.selected - Admin-only "All courses" option for full visibility - Automatically refetch courses when status changes Translations added (ru/en/zh): - activeCourses: "Активные курсы" / "Active Courses" / "活跃课程" - archivedCourses: "Архив курсов" / "Archived Courses" / "归档课程" - allCourses: "Все курсы" / "All Courses" / "所有课程" Backend support: - Backend already supports ?status= query parameter - active (default), archived, all This allows students with academic debt to access archived course materials while keeping main page focused on current courses. --- frontend/courses-front/src/api/index.js | 4 +- .../src/components/course-list/index.jsx | 54 +++++++++++++++++-- .../src/locales/en/translation.json | 5 +- .../src/locales/ru/translation.json | 5 +- .../src/locales/zh/translation.json | 4 +- 5 files changed, 62 insertions(+), 10 deletions(-) diff --git a/frontend/courses-front/src/api/index.js b/frontend/courses-front/src/api/index.js index 9889885..c7a265c 100644 --- a/frontend/courses-front/src/api/index.js +++ b/frontend/courses-front/src/api/index.js @@ -25,8 +25,8 @@ function formatValidationError(err) { return fieldLabel ? `${fieldLabel}: ${err.msg}` : err.msg; } -export const fetchCourses = async () => { - const response = await fetch(`${API_BASE_URL}/courses`); +export const fetchCourses = async (status = "active") => { + const response = await fetch(`${API_BASE_URL}/courses?status=${status}`); if (response.status === 429) { let errorMessage = "Превышен лимит запросов. Пожалуйста, подождите немного и попробуйте снова."; try { diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index 9fa7aba..2bc9675 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -35,6 +35,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { const { t, i18n } = useTranslation(); const [courses, setCourses] = useState([]); + const [courseStatus, setCourseStatus] = useState("active"); // "active", "archived", "all" const [expandedCourse, setExpandedCourse] = useState(null); const [editingCourseId, setEditingCourseId] = useState(null); const [editContent, setEditContent] = useState(""); @@ -59,10 +60,10 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { }; useEffect(() => { - fetchCourses() + fetchCourses(courseStatus) .then(setCourses) .catch(() => showSnackbar(t("errorLoadingCourses"), "error")); - }, [t]); + }, [t, courseStatus]); const handleDeleteConfirmation = (courseId) => { setSelectedCourseId(courseId); @@ -78,7 +79,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { if (response.ok) { showSnackbar(t("courseDeleted"), "success"); - fetchCourses().then(setCourses); + fetchCourses(courseStatus).then(setCourses); } else { const data = await response.json(); showSnackbar(data.detail || t("errorDeletingCourse"), "error"); @@ -146,7 +147,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { showSnackbar(t("changesSaved"), "success"); setEditingCourseId(null); setIsFullscreen(false); - fetchCourses().then(setCourses); + fetchCourses(courseStatus).then(setCourses); } else { const data = await response.json(); showSnackbar(data.detail || t("errorSavingCourse"), "error"); @@ -183,7 +184,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { if (response.ok) { showSnackbar(t("courseUploaded"), "success"); - fetchCourses().then(setCourses); + fetchCourses(courseStatus).then(setCourses); } else { const data = await response.json(); showSnackbar(data.detail || t("errorUploadingCourse"), "error"); @@ -203,6 +204,49 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { return ( + {/* Переключатель статусов курсов */} + + + + {isAdmin && ( + + )} + + {/* Выпадающий список выбора языка */} { ))} + {/* Переключатель статусов курсов - компактный, в углу */} + + + + {isAdmin && ( + + )} + + {isAdmin && ( <> From 726f551c97149fee8fdb1be2c6e55a0dad60e838 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 13:19:41 +0000 Subject: [PATCH 25/30] Move course status toggle lower to avoid overlap with admin button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed position from top:60px to top:110px to prevent overlap with "Для преподавателей" button. --- frontend/courses-front/src/components/course-list/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index 5e76be3..bb94a48 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -238,7 +238,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { Date: Thu, 11 Dec 2025 23:14:06 +0000 Subject: [PATCH 26/30] Fix language selector dropdown z-index to appear above all buttons Increased z-index from 3000 to 3200 and added MenuProps to ensure dropdown menu appears above admin button (z-index 3100) and course status toggle (z-index 2999). --- .../courses-front/src/components/course-list/index.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index bb94a48..6ac4249 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -212,7 +212,7 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { position: "fixed", top: 16, right: 16, - zIndex: 3000, + zIndex: 3200, minWidth: 120, backgroundColor: "#555", color: "#fff", @@ -226,6 +226,13 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { }, ".MuiSvgIcon-root": { color: "#fff" }, }} + MenuProps={{ + PaperProps: { + sx: { + zIndex: 3200, + }, + }, + }} > {languages.map(({ code, label }) => ( From ef6c1e4f89ce2fcb34562c22658fdf3dd897553d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 23:18:04 +0000 Subject: [PATCH 27/30] Add disablePortal to language selector to fix z-index issues Using disablePortal renders the dropdown in normal DOM hierarchy instead of Portal, which should properly apply z-index stacking. --- frontend/courses-front/src/components/course-list/index.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/courses-front/src/components/course-list/index.jsx b/frontend/courses-front/src/components/course-list/index.jsx index 6ac4249..f514f21 100644 --- a/frontend/courses-front/src/components/course-list/index.jsx +++ b/frontend/courses-front/src/components/course-list/index.jsx @@ -227,6 +227,10 @@ export const CourseList = ({ onSelectCourse, isAdmin = false }) => { ".MuiSvgIcon-root": { color: "#fff" }, }} MenuProps={{ + disablePortal: true, + sx: { + zIndex: 3200, + }, PaperProps: { sx: { zIndex: 3200, From e4f07f5a3c39b405789aae6bfd0ff96a7dcbb8b4 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Tue, 16 Dec 2025 18:43:02 +0300 Subject: [PATCH 28/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 225f959..994423c 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -100,5 +100,21 @@ course: language: python max-matches: 100 local-path: lab2 + "3": + github-prefix: fs-lab3 + short-name: ДЗ3 + taskid-max: 10 + penalty-max: 6 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 100 + local-path: lab3 misc: requests-timeout: 5 From 94fdc516307f7b0ffd4d96ce72259b72b7dc6669 Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Mon, 22 Dec 2025 20:07:22 +0300 Subject: [PATCH 29/30] Update operating-systems-2025.yaml --- courses/operating-systems-2025.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/courses/operating-systems-2025.yaml b/courses/operating-systems-2025.yaml index f9f10be..3e89675 100644 --- a/courses/operating-systems-2025.yaml +++ b/courses/operating-systems-2025.yaml @@ -106,7 +106,7 @@ course: short-name: ЛР2 taskid-max: 20 taskid-shift: 4 - penalty-max: 9 + penalty-max: 8 ci: - workflows files: @@ -174,7 +174,7 @@ course: github-prefix: os-task4 short-name: ЛР4 taskid-max: 30 - penalty-max: 8 + penalty-max: 7 ci: - workflows files: @@ -204,7 +204,7 @@ course: github-prefix: os-task5 short-name: ЛР5 taskid-max: 30 - penalty-max: 10 + penalty-max: 8 # ignore-completion-date: True ci: workflows: From 9c9c04c0fdbd1da5c79116f9e72a52409cf1176a Mon Sep 17 00:00:00 2001 From: Mark Polyak Date: Fri, 26 Dec 2025 18:47:43 +0300 Subject: [PATCH 30/30] Update fundamental-statistics-2025.yaml --- courses/fundamental-statistics-2025.yaml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/courses/fundamental-statistics-2025.yaml b/courses/fundamental-statistics-2025.yaml index 994423c..2c72e7e 100644 --- a/courses/fundamental-statistics-2025.yaml +++ b/courses/fundamental-statistics-2025.yaml @@ -61,7 +61,7 @@ course: short-name: ДЗ1 taskid-max: 10 # ignore-task-id: True - penalty-max: 6 + penalty-max: 5 score: patterns: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" @@ -88,7 +88,7 @@ course: github-prefix: fs-lab2 short-name: ДЗ2 taskid-max: 10 - penalty-max: 6 + penalty-max: 5 score: patterns: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" @@ -104,7 +104,7 @@ course: github-prefix: fs-lab3 short-name: ДЗ3 taskid-max: 10 - penalty-max: 6 + penalty-max: 5 score: patterns: - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" @@ -116,5 +116,21 @@ course: language: python max-matches: 100 local-path: lab3 + "4": + github-prefix: fs-lab4 + short-name: ДЗ4 + taskid-max: 10 + penalty-max: 5 + score: + patterns: + - 'ПРЕДВАРИТЕЛЬНАЯ.*?ОЦЕНКА.*?ЖУРНАЛ:\s*(\d+(?:[.,]\d+)?)' # Гибкий паттерн для "ПРЕДВАРИТЕЛЬНАЯ ОЦЕНКА В ЖУРНАЛ: 10.0" + ci: + - workflows + files: + - exercises.ipynb + moss: + language: python + max-matches: 100 + local-path: lab4 misc: requests-timeout: 5