/
serkatrinaa
/
final_project_base
Обзор
Документация
Войти
/
serkatrinaa
/
final_project_base
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/analyzers.py
706 строк
24 KB
Katya Mashnina
project done
28 дек 2025, 23:22
28 дек 2025, 23:22
cc88ff0
Код
Авторство
О чём код?
""" Analyzers module for essay grading system. Contains classes for different types of text analysis: - Readability analysis - Grammar checking - Plagiarism detection - Sentiment analysis """ import math import re from abc import ABC, abstractmethod from dataclasses import dataclass from typing import List, Optional # Try to import optional dependencies, use fallbacks if unavailable try: import language_tool_python HAS_LANGUAGE_TOOL = True except ImportError: HAS_LANGUAGE_TOOL = False try: import textstat HAS_TEXTSTAT = True except ImportError: HAS_TEXTSTAT = False try: from textblob import TextBlob HAS_TEXTBLOB = True except ImportError: HAS_TEXTBLOB = False @dataclass class AnalysisResult: """Base class for analysis results.""" score: float max_score: float feedback: str details: Optional[dict] = None class BaseAnalyzer(ABC): """Abstract base class for all analyzers.""" @abstractmethod def analyze(self, text: str) -> AnalysisResult: """Analyze the given text and return results.""" pass class ReadabilityAnalyzer(BaseAnalyzer): """ Analyzer for text readability using Flesch-Kincaid metrics. Evaluates how easy or difficult a text is to read. """ MAX_SCORE = 25.0 def _calculate_flesch_score(self, text: str) -> float: """Calculate Flesch Reading Ease score manually.""" sentences = re.split(r'[.!?]+', text) sentences = [s.strip() for s in sentences if s.strip()] words = text.split() if not sentences or not words: return 50.0 # Count syllables (simplified for Russian/English) vowels = "аеёиоуыэюяaeiouy" syllable_count = 0 for word in words: word_lower = word.lower() count = sum(1 for char in word_lower if char in vowels) syllable_count += max(count, 1) total_sentences = len(sentences) total_words = len(words) total_syllables = syllable_count # Flesch Reading Ease formula asl = total_words / total_sentences # Average Sentence Length asw = total_syllables / total_words # Average Syllables per Word flesch = 206.835 - (1.015 * asl) - (84.6 * asw) return max(0, min(100, flesch)) def _calculate_grade_level(self, text: str) -> float: """Calculate Flesch-Kincaid Grade Level manually.""" sentences = re.split(r'[.!?]+', text) sentences = [s.strip() for s in sentences if s.strip()] words = text.split() if not sentences or not words: return 8.0 vowels = "аеёиоуыэюяaeiouy" syllable_count = 0 for word in words: word_lower = word.lower() count = sum(1 for char in word_lower if char in vowels) syllable_count += max(count, 1) total_sentences = len(sentences) total_words = len(words) total_syllables = syllable_count asl = total_words / total_sentences asw = total_syllables / total_words grade = (0.39 * asl) + (11.8 * asw) - 15.59 return max(0, grade) def analyze(self, text: str) -> AnalysisResult: """ Analyze text readability. Args: text: The essay text to analyze. Returns: AnalysisResult with readability score and feedback. """ if HAS_TEXTSTAT: flesch_score = textstat.flesch_reading_ease(text) flesch_kincaid_grade = textstat.flesch_kincaid_grade(text) gunning_fog = textstat.gunning_fog(text) else: flesch_score = self._calculate_flesch_score(text) flesch_kincaid_grade = self._calculate_grade_level(text) gunning_fog = flesch_kincaid_grade * 1.2 # Approximation # Normalize Flesch score (0-100) to our scale # Optimal range for essays: 30-70 if 30 <= flesch_score <= 70: normalized_score = self.MAX_SCORE elif flesch_score > 70: # Too simple normalized_score = max( self.MAX_SCORE * 0.6, self.MAX_SCORE - (flesch_score - 70) * 0.3 ) else: # Too complex normalized_score = max( self.MAX_SCORE * 0.5, self.MAX_SCORE - (30 - flesch_score) * 0.4 ) feedback = self._generate_feedback(flesch_score, flesch_kincaid_grade) return AnalysisResult( score=round(normalized_score, 2), max_score=self.MAX_SCORE, feedback=feedback, details={ "flesch_reading_ease": round(flesch_score, 2), "flesch_kincaid_grade": round(flesch_kincaid_grade, 2), "gunning_fog_index": round(gunning_fog, 2) } ) def _generate_feedback( self, flesch_score: float, grade_level: float ) -> str: """Generate human-readable feedback based on scores.""" if flesch_score >= 70: readability = "очень легко читается" suggestion = "Рассмотрите возможность усложнить лексику." elif flesch_score >= 50: readability = "достаточно легко читается" suggestion = "Хороший баланс сложности." elif flesch_score >= 30: readability = "средняя сложность" suggestion = "Оптимальный уровень для академического текста." else: readability = "сложно читается" suggestion = "Попробуйте упростить предложения." return ( f"Текст {readability} (Flesch: {flesch_score:.1f}). " f"Уровень: {grade_level:.1f} класс. {suggestion}" ) class GrammarAnalyzer(BaseAnalyzer): """ Analyzer for grammar and spelling errors. Uses LanguageTool for comprehensive grammar checking, with fallback to basic pattern matching. """ MAX_SCORE = 25.0 # Common grammar patterns for basic checking COMMON_ERRORS = [ (r'\s{3,}', 'Множественные пробелы'), (r',,+', 'Повторяющиеся запятые'), (r'\.\.(?!\.)', 'Двойные точки'), (r'\s+[,.:;!?]', 'Пробел перед знаком препинания'), ] def __init__(self, language: str = "ru"): """ Initialize grammar analyzer. Args: language: Language code for grammar checking. """ self.language = language self._tool = None self._has_tool = HAS_LANGUAGE_TOOL @property def tool(self): """Lazy initialization of LanguageTool.""" if self._has_tool and self._tool is None: try: self._tool = language_tool_python.LanguageTool(self.language) except Exception: self._has_tool = False return self._tool def _basic_grammar_check(self, text: str) -> List[dict]: """Fallback grammar check using regex patterns.""" errors = [] for pattern, message in self.COMMON_ERRORS: matches = re.finditer(pattern, text, re.IGNORECASE) for match in matches: start = max(0, match.start() - 20) end = min(len(text), match.end() + 20) context = text[start:end] errors.append({ "message": message, "context": f"...{context}...", "suggestions": [], "category": "TYPOGRAPHY" }) return errors def analyze(self, text: str) -> AnalysisResult: """ Analyze grammar and spelling in text. Args: text: The essay text to analyze. Returns: AnalysisResult with grammar score and error details. """ word_count = len(text.split()) if self._has_tool and self.tool: matches = self.tool.check(text) error_count = len(matches) errors = self._extract_errors(matches[:10]) else: errors = self._basic_grammar_check(text) error_count = len(errors) errors = errors[:10] # Calculate error rate error_rate = error_count / max(word_count, 1) * 100 # Score based on error rate if error_rate == 0: score = self.MAX_SCORE elif error_rate < 1: score = self.MAX_SCORE * 0.9 elif error_rate < 3: score = self.MAX_SCORE * 0.75 elif error_rate < 5: score = self.MAX_SCORE * 0.6 else: score = max(self.MAX_SCORE * 0.3, self.MAX_SCORE * (1 - error_rate / 20)) feedback = self._generate_feedback(error_count, word_count) return AnalysisResult( score=round(score, 2), max_score=self.MAX_SCORE, feedback=feedback, details={ "total_errors": error_count, "word_count": word_count, "error_rate_percent": round(error_rate, 2), "errors": errors, "using_language_tool": self._has_tool and self.tool is not None } ) def _extract_errors(self, matches) -> List[dict]: """Extract error information from matches.""" errors = [] for match in matches: errors.append({ "message": match.message, "context": match.context, "suggestions": match.replacements[:3] if match.replacements else [], "category": match.category }) return errors def _generate_feedback(self, error_count: int, word_count: int) -> str: """Generate feedback based on error analysis.""" if error_count == 0: return "Отлично! Грамматических ошибок не обнаружено." elif error_count <= 3: return ( f"Найдено {error_count} ошибок. " "Текст в целом написан грамотно." ) elif error_count <= 10: return ( f"Обнаружено {error_count} ошибок. " "Рекомендуется внимательно проверить текст." ) else: return ( f"Найдено {error_count} ошибок. " "Текст требует серьёзной корректуры." ) def close(self): """Close the LanguageTool instance.""" if self._tool is not None: self._tool.close() self._tool = None class PlagiarismAnalyzer(BaseAnalyzer): """ Analyzer for plagiarism detection. Compares text against a reference corpus using similarity metrics. """ MAX_SCORE = 25.0 def __init__(self, reference_texts: Optional[List[str]] = None): """ Initialize plagiarism analyzer. Args: reference_texts: List of reference texts to check against. """ self.reference_texts = reference_texts or [] def add_reference(self, text: str): """Add a reference text for plagiarism checking.""" self.reference_texts.append(text) def analyze(self, text: str) -> AnalysisResult: """ Analyze text for potential plagiarism. Args: text: The essay text to analyze. Returns: AnalysisResult with originality score. """ if not self.reference_texts: return AnalysisResult( score=self.MAX_SCORE, max_score=self.MAX_SCORE, feedback="Референсные тексты не загружены. Проверка не выполнена.", details={"similarity_scores": [], "max_similarity": 0} ) text_shingles = self._get_shingles(text) similarity_scores = [] for ref_text in self.reference_texts: ref_shingles = self._get_shingles(ref_text) similarity = self._jaccard_similarity(text_shingles, ref_shingles) similarity_scores.append(round(similarity * 100, 2)) max_similarity = max(similarity_scores) if similarity_scores else 0 originality = 100 - max_similarity # Score based on originality if originality >= 90: score = self.MAX_SCORE elif originality >= 70: score = self.MAX_SCORE * 0.8 elif originality >= 50: score = self.MAX_SCORE * 0.5 else: score = self.MAX_SCORE * 0.2 feedback = self._generate_feedback(originality) return AnalysisResult( score=round(score, 2), max_score=self.MAX_SCORE, feedback=feedback, details={ "originality_percent": round(originality, 2), "max_similarity_percent": max_similarity, "texts_compared": len(self.reference_texts) } ) def _get_shingles(self, text: str, k: int = 3) -> set: """Create k-shingles from text.""" text = re.sub(r'\s+', ' ', text.lower().strip()) words = text.split() if len(words) < k: return {tuple(words)} return {tuple(words[i:i + k]) for i in range(len(words) - k + 1)} def _jaccard_similarity(self, set1: set, set2: set) -> float: """Calculate Jaccard similarity between two sets.""" if not set1 or not set2: return 0.0 intersection = len(set1 & set2) union = len(set1 | set2) return intersection / union if union > 0 else 0.0 def _generate_feedback(self, originality: float) -> str: """Generate feedback based on originality score.""" if originality >= 90: return f"Оригинальность текста: {originality:.1f}%. Отличный результат!" elif originality >= 70: return ( f"Оригинальность: {originality:.1f}%. " "Приемлемый уровень, но есть совпадения." ) elif originality >= 50: return ( f"Оригинальность: {originality:.1f}%. " "Обнаружены значительные совпадения с источниками." ) else: return ( f"Оригинальность: {originality:.1f}%. " "Высокий уровень заимствований!" ) class SentimentAnalyzer(BaseAnalyzer): """ Analyzer for sentiment and tone of the text. Evaluates the emotional tone and objectivity of writing. """ MAX_SCORE = 25.0 # Word lists for basic sentiment analysis POSITIVE_WORDS = { 'хороший', 'отличный', 'прекрасный', 'замечательный', 'великолепный', 'превосходный', 'лучший', 'позитивный', 'успешный', 'эффективный', 'важный', 'полезный', 'интересный', 'перспективный', 'развитие', 'good', 'great', 'excellent', 'wonderful', 'best', 'positive' } NEGATIVE_WORDS = { 'плохой', 'ужасный', 'проблема', 'риск', 'опасность', 'негативный', 'сложный', 'трудный', 'невозможный', 'неудачный', 'провал', 'bad', 'terrible', 'problem', 'risk', 'danger', 'negative', 'fail' } SUBJECTIVE_MARKERS = { 'я считаю', 'по моему мнению', 'мне кажется', 'я думаю', 'безусловно', 'очевидно', 'конечно', 'несомненно', 'i think', 'i believe', 'in my opinion', 'obviously' } def _basic_sentiment_analysis(self, text: str) -> tuple: """Fallback sentiment analysis using word lists.""" text_lower = text.lower() words = re.findall(r'\b\w+\b', text_lower) positive_count = sum(1 for w in words if w in self.POSITIVE_WORDS) negative_count = sum(1 for w in words if w in self.NEGATIVE_WORDS) total_sentiment_words = positive_count + negative_count if total_sentiment_words == 0: polarity = 0.0 else: polarity = (positive_count - negative_count) / total_sentiment_words # Calculate subjectivity based on markers subjective_count = sum( 1 for marker in self.SUBJECTIVE_MARKERS if marker in text_lower ) word_count = len(words) subjectivity = min(1.0, subjective_count * 0.1 + 0.2) return polarity, subjectivity def analyze(self, text: str) -> AnalysisResult: """ Analyze sentiment and subjectivity of text. Args: text: The essay text to analyze. Returns: AnalysisResult with sentiment analysis. """ if HAS_TEXTBLOB: blob = TextBlob(text) polarity = blob.sentiment.polarity subjectivity = blob.sentiment.subjectivity else: polarity, subjectivity = self._basic_sentiment_analysis(text) # For academic writing, we prefer neutral and objective tone # Score based on how neutral and objective the text is neutrality_score = 1 - abs(polarity) # 0 to 1 objectivity_score = 1 - subjectivity # 0 to 1 combined_score = (neutrality_score * 0.4 + objectivity_score * 0.6) score = combined_score * self.MAX_SCORE tone = self._determine_tone(polarity) feedback = self._generate_feedback(polarity, subjectivity, tone) return AnalysisResult( score=round(score, 2), max_score=self.MAX_SCORE, feedback=feedback, details={ "polarity": round(polarity, 3), "subjectivity": round(subjectivity, 3), "tone": tone, "neutrality_score": round(neutrality_score, 3), "objectivity_score": round(objectivity_score, 3) } ) def _determine_tone(self, polarity: float) -> str: """Determine the overall tone based on polarity.""" if polarity > 0.3: return "позитивный" elif polarity < -0.3: return "негативный" else: return "нейтральный" def _generate_feedback( self, polarity: float, subjectivity: float, tone: str ) -> str: """Generate feedback on text tone.""" feedback_parts = [f"Тональность текста: {tone}."] if subjectivity > 0.6: feedback_parts.append( "Текст субъективен. Для академического стиля " "рекомендуется более объективное изложение." ) elif subjectivity < 0.3: feedback_parts.append( "Текст объективен, что хорошо для академического стиля." ) else: feedback_parts.append("Баланс объективности приемлемый.") return " ".join(feedback_parts) class StructureAnalyzer(BaseAnalyzer): """ Analyzer for essay structure. Evaluates paragraph organization, transitions, and overall structure. """ MAX_SCORE = 25.0 def analyze(self, text: str) -> AnalysisResult: """ Analyze the structure of the essay. Args: text: The essay text to analyze. Returns: AnalysisResult with structure analysis. """ paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()] sentences = self._count_sentences(text) word_count = len(text.split()) # Evaluate structure components has_intro = len(paragraphs) >= 1 and len(paragraphs[0].split()) >= 30 has_body = len(paragraphs) >= 3 has_conclusion = ( len(paragraphs) >= 1 and len(paragraphs[-1].split()) >= 20 ) avg_paragraph_length = word_count / max(len(paragraphs), 1) # Calculate structure score score_components = { "has_intro": 0.2 if has_intro else 0, "has_body": 0.3 if has_body else 0, "has_conclusion": 0.2 if has_conclusion else 0, "paragraph_balance": self._evaluate_paragraph_balance(paragraphs), "sentence_variety": self._evaluate_sentence_variety(text) } total_component_score = sum(score_components.values()) score = total_component_score * self.MAX_SCORE feedback = self._generate_feedback( paragraphs, has_intro, has_body, has_conclusion ) return AnalysisResult( score=round(score, 2), max_score=self.MAX_SCORE, feedback=feedback, details={ "paragraph_count": len(paragraphs), "sentence_count": sentences, "word_count": word_count, "avg_paragraph_length": round(avg_paragraph_length, 1), "structure_components": score_components } ) def _count_sentences(self, text: str) -> int: """Count the number of sentences in text.""" sentence_endings = re.findall(r'[.!?]+', text) return len(sentence_endings) def _evaluate_paragraph_balance(self, paragraphs: List[str]) -> float: """Evaluate how balanced paragraph lengths are.""" if len(paragraphs) < 2: return 0.1 lengths = [len(p.split()) for p in paragraphs] avg_length = sum(lengths) / len(lengths) if avg_length == 0: return 0.1 variance = sum((l - avg_length) ** 2 for l in lengths) / len(lengths) cv = (variance ** 0.5) / avg_length # Coefficient of variation # Lower CV means more balanced paragraphs if cv < 0.3: return 0.15 elif cv < 0.5: return 0.1 else: return 0.05 def _evaluate_sentence_variety(self, text: str) -> float: """Evaluate sentence length variety.""" sentences = re.split(r'[.!?]+', text) sentences = [s.strip() for s in sentences if s.strip()] if len(sentences) < 3: return 0.1 lengths = [len(s.split()) for s in sentences] unique_lengths = len(set(lengths)) variety_ratio = unique_lengths / len(lengths) if variety_ratio > 0.5: return 0.15 elif variety_ratio > 0.3: return 0.1 else: return 0.05 def _generate_feedback( self, paragraphs: List[str], has_intro: bool, has_body: bool, has_conclusion: bool ) -> str: """Generate structural feedback.""" issues = [] if not has_intro: issues.append("Вступление слишком короткое или отсутствует") if not has_body: issues.append("Недостаточно абзацев в основной части") if not has_conclusion: issues.append("Заключение слишком короткое или отсутствует") if not issues: return ( f"Структура эссе хорошая: {len(paragraphs)} абзацев, " "есть вступление, основная часть и заключение." ) else: return "Замечания по структуре: " + "; ".join(issues) + "."