# buddy_math_server/orchestrator.py - V273.0 (SMART CLASSIFICATION + FAST PATH) import json, re, os, prompts, asyncio, time from typing import List, Dict, Optional, Any import sympy as sp import logging, re import ocr_strip_engine # V300: Stitch & Strip OCR engine from utils.safe_json import safe_extract_json # V1.0: Canonical JSON extractor from domain.math_validator import MathPolygraph # V1.0: SymPy Polygraph from geometric_sanity import run_geometric_sanity # V1.0: Geometric Sanity Engine from domain.processing_strategy import ProcessingStrategy from domain.ontology import get_allowed_concepts, get_pedagogical_tag from domain.math_normalizer import MathCanonicalizer from utils.math_utils import sanitize_latex_for_sympy, aggressive_sympy_sanitizer from domain.curriculum_classifier import CurriculumClassifier from domain.proposal_engine import ProposalEngine from domain.risk_engine import CognitiveRiskEngine from domain.pedagogical_renderer import PedagogicalRenderer from domain.validator import ConsistencyGate from smart_solver import sign_step, resolve_ast_target, execute_action import domain.telemetry as telemetry from domain.schemas import BuddyEvent, BuddyState # V8.5: Streaming contract from firebase_manager import firebase_manager from config import IS_PRODUCTION, ENV, GEMINI_MODEL, CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_MEDIUM import google.generativeai as genai from pydantic import BaseModel, Field # V318.0: Tutor Response Schema for Structured JSON Output class TutorInternalAnalytics(BaseModel): topic: str = Field(description="The mathematical topic being discussed") intent: str = Field(description="The student's intent: 'SOLVE', 'CHECK', or 'CHAT'") mastery_score: int = Field(description="Estimated mastery score (0-100) based on this interaction") error_analysis: Optional[str] = Field(description="Brief analysis of any errors found") class TutorResponseSchema(BaseModel): student_message: str = Field(description="The encouraging pedagogical response for the student") internal_analytics: TutorInternalAnalytics = Field(description="Metadata for system analysis") # V8.6.9: Global Guardrails (Increased for High-Complexity 5-Unit Problems - V317.8) GLOBAL_TOKEN_LIMIT = 100000 GLOBAL_TIMEOUT_SEC = 300 # ==================== V7.2: TICKET 1 โ€” AST ENRICHMENT HELPERS ==================== def collect_all_steps(data: dict) -> list: """ V1.0 Polygraph Helper: Deep extraction of all step objects from a response dict. Future-proofed to handle nested sub_sections and explanation_steps. """ steps = [] print(f"๐Ÿ” [DEBUG] data type: {type(data)}") if not isinstance(data, dict): print(f"โš ๏ธ [V1.0] collect_all_steps: Expected dict, got {type(data)}") return [] for section in data.get("sections", []): steps.extend(section.get("steps", [])) # Future-proofing: handle nested structures for sub in section.get("sub_sections", []): steps.extend(sub.get("steps", [])) steps.extend(section.get("explanation_steps", [])) return steps def build_ast_metadata(math_input: str, category: str) -> dict: """ V7.2 Ticket 1: Builds a rich AST metadata object to pass to LLM #1 (Planner). The Planner never receives the raw math string โ€” only this structured metadata. """ variables = [] constraints = [] # V7.3: domain constraints (placeholder) detected_operations = [str(category)] estimated_complexity = 0.5 try: parts = aggressive_sympy_sanitizer(math_input) free_syms = set() for clean_part in parts: try: expr = sp.sympify(clean_part.replace('=', '-'), evaluate=False) free_syms.update(expr.free_symbols) except Exception as e: logging.debug(f"[AST_METADATA] SymPy parse failed for part: {e}") pass variables = sorted([str(s) for s in free_syms]) raw_complexity = CurriculumClassifier.estimate_complexity(math_input) estimated_complexity = round(raw_complexity / 10.0, 2) except Exception as e: logging.warning(f"[AST_METADATA] Failed to enrich metadata: {e}") # Build node registry for Planner (IDs โ†’ expressions) ast_registry = {} try: parts = aggressive_sympy_sanitizer(math_input) for i, part in enumerate(parts): ast_registry[f"ast_node_{i}"] = part except Exception as e: logging.debug(f"[AST_METADATA] Registry build failed: {e}") ast_registry["ast_node_0"] = str(math_input) return { "variables": variables, "constraints": constraints, "detected_operations": detected_operations, "estimated_complexity": estimated_complexity, "ast_registry": ast_registry # Shared secretly with solver; NOT sent to LLM } def _abstract_visual_context(data_anchor: dict) -> dict: """ V7.2 Ticket 1: Strips raw OCR/visual payload and converts to abstract metadata. The Planner NEVER receives raw image data. """ if not data_anchor: return {} # Preserve only safe, abstract fields abstract = {} graph_relations = [] if "graphs" in data_anchor: for i, g in enumerate(data_anchor["graphs"]): graph_relations.append({ "graph": chr(ord("I") + i), # I, II, III... "zeros": g.get("zeros", 0), "type": g.get("type", "unknown") }) if graph_relations: abstract["graph_relations"] = graph_relations return abstract # ==================== V7.2: TICKET 5 โ€” UI GATE WHITELIST SCAN ==================== def scan_for_math_leakage(rendered_text: str) -> bool: """ V7.2 Ticket 5: Full Whitelist scan (NOT a blacklist). After removing {{...}} placeholders, the remaining text may ONLY contain: - Hebrew letters (Unicode block) - English letters (for regular words) - Spaces and basic punctuation (. , ? ! ' " - :) Any digit-letter combo, math symbols, trig functions, or equals signs โ†’ REJECT. """ # Remove all valid placeholders first clean_text = re.sub(r'\{\{\w+\}\}', '', rendered_text).strip() if not clean_text: return True # Text was purely placeholders โ€” safe ALLOWED_PATTERN = r'^[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\']+$' if re.match(ALLOWED_PATTERN, clean_text): return True # Log the exact leaking characters for forensics violations = re.sub(r'[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\']+', '', clean_text) logging.warning(f"[UI_GATE] Math leakage detected! Offending chars: '{violations[:50]}'") return False def extract_and_parse_json(text: str): """V5.7.5 โ†’ V1.0: Delegates to canonical safe_extract_json.""" return safe_extract_json(text, caller="ORCHESTRATOR_LEGACY") def validate_and_sanitize_response(resp_json, category="GENERAL"): """V4.2.16: Validator ืžื“ื•ื™ืง - ื—ื•ืกื ืงื•ื“, ืžืืคืฉืจ ื’ื™ืื•ืžื˜ืจื™ื” (ABC)""" has_error = False forbidden_terms = ["ื ื’ื–ืจืช", "ื’ื–ื™ืจื”", "ืืกื™ืžืคื˜ื•ื˜ื”", "ื ืงื•ื“ืช ืงื™ืฆื•ืŸ"] print(f"๐Ÿ›ก๏ธ [BIT-LOG: VALIDATOR] Checking section in category: {category}") if "sections" in resp_json: for section in resp_json["sections"]: for step in section.get("steps", []): text = step.get("explanation_text", "") # 1. ื—ืกื™ืžืช ื—ื“ื•"ื ื‘ืืœื’ื‘ืจื” (Normalize category for Case Sensitivity Fix) if category.upper() != "INVESTIGATION" and any(t in text for t in forbidden_terms): print(f"๐Ÿšจ [BIT-LOG: VALIDATOR] Calculus leak detected!") has_error = True # 2. ื—ืกื™ืžืช ืคื•ื ืงืฆื™ื•ืช/ืงื•ื“ (f(x) , import) ืื‘ืœ ื”ืฉืืจืช ABC (Relaxed for False Positives) if re.search(r'\b(import|def|class|lambda)\b', text): print(f"๐Ÿšจ [BIT-LOG: VALIDATOR] Code/Math leak in text: '{text[:20]}'") has_error = True step["explanation_text"] = "ื”ืกื‘ืจ ืœื ื–ืžื™ืŸ ืขืงื‘ ื—ืจื™ื’ื” ืžื”ื—ื•ื–ื” ื”ืคื“ื’ื•ื’ื™." resp_json["logic_error"] = resp_json.get("logic_error", False) or has_error # V317.5: UI Sanitization Layer if not resp_json.get("logic_error"): resp_json = sanitize_llm_output(resp_json) return resp_json def unify_data_anchor(raw_data): """V317.5: Smart Data Anchor Unification (Prevents key overwrite)""" if isinstance(raw_data, dict): return raw_data if isinstance(raw_data, list): unified = {} for item in raw_data: if not isinstance(item, dict): continue for key, value in item.items(): if key in unified: # ืื ื”ืžืคืชื— ื›ื‘ืจ ืงื™ื™ื, ื ื”ืคื•ืš ืื•ืชื• ืœืจืฉื™ืžื” ื•ื ื•ืกื™ืฃ ืืœื™ื• if isinstance(unified[key], list): if value not in unified[key]: unified[key].append(value) else: if unified[key] != value: unified[key] = [unified[key], value] else: unified[key] = value return unified return {} # Fallback def sanitize_llm_output(json_response): """V317.5: Cleans technical errors (SYMPY_PARSE_ERROR) and Hebrew from LaTeX.""" if not isinstance(json_response, dict): return json_response if "steps" in json_response: for step in json_response["steps"]: block_math = step.get("block_math", "") if block_math: # Mission 2: ื–ื™ื”ื•ื™ ืฉื’ื™ืื•ืช ืฉืœ SymPy if "SYMPY_PARSE_ERROR" in block_math: step["block_math"] = "" step["content_mixed"] = step.get("content_mixed", "") + "\n(ื”ืžืฉื•ื•ืื” ื”ื•ืกืชืจื” ืขืงื‘ ืงื•ืฉื™ ื‘ืชืฆื•ื’ื”)." # Mission 2: ื–ื™ื”ื•ื™ ืื•ืชื™ื•ืช ื‘ืขื‘ืจื™ืช ื‘ืชื•ืš ื”-LaTeX elif re.search(r'[ื-ืช]', block_math): # ืžืขื‘ื™ืจื™ื ืืช ื”ืชื•ื›ืŸ ืœืฉื“ื” ื”ื˜ืงืกื˜ ื•ืžื•ื—ืงื™ื ืืช ื”ื‘ืœื•ืง ื”ืžืชืžื˜ื™ clean_math = block_math.replace('\\text{', '').replace('}', '').replace('$', '') step["content_mixed"] = step.get("content_mixed", "") + f"\n[{clean_math}]" step["block_math"] = "" # V280.0: Also check final_answer if "final_answer" in json_response and "SYMPY_PARSE_ERROR" in str(json_response["final_answer"]): json_response["final_answer"] = "ื”ืชืงื‘ืœื” ืชืฉื•ื‘ื” ืžื•ืจื›ื‘ืช (ืจืื” ืฉืœื‘ื™ื ืžืœืื™ื)." return json_response import asyncio async def safe_llm_call(generator_func, timeout_seconds=45.0): try: # ื”ื’ื‘ืœืช ื–ืžืŸ ืจื™ืฆื” ืœืžื ื™ืขืช ืชืงื™ืขื•ืช ืฉืจืช ื‘ืžืงืจื” ืฉืœ ืจืฉืช ืื™ื˜ื™ืช raw_output = await asyncio.wait_for(generator_func(), timeout=timeout_seconds) # Handle if the function already returned parsed dict/list if isinstance(raw_output, (dict, list)): return raw_output if hasattr(raw_output, 'text'): raw_output = raw_output.text # V1.0: Use canonical safe_extract_json (logs RAW, fail-closed) result = safe_extract_json(raw_output, caller="SAFE_LLM_CALL") if isinstance(result, dict) and result.get("logic_error"): return build_standard_response( logic_error=True, error_type="STREAM_OR_CONTRACT_FAILURE", final_answer="ื”ืชืฉื•ื‘ื” ืœื ื”ืชืงื‘ืœื” ื‘ืฆื•ืจื” ืžืœืื”. ื ืกื• ืœืกืจื•ืง ืฉื•ื‘ ๐Ÿ“ธ", sections=[] ) return result except Exception as e: logger.error(f"๐Ÿšจ [FINAL_SHIELD] Exception caught: {str(e)}") # ื—ื–ืจื” ื‘ื˜ื•ื—ื” ืœืžื‘ื ื” ืฉื’ื™ืื” ืชืงื ื™ ืœ-Flutter return build_standard_response( logic_error=True, error_type="STREAM_OR_CONTRACT_FAILURE", final_answer="ื”ืชืฉื•ื‘ื” ืœื ื”ืชืงื‘ืœื” ื‘ืฆื•ืจื” ืžืœืื”. ื ืกื• ืœืกืจื•ืง ืฉื•ื‘ ๐Ÿ“ธ", sections=[] ) def enforce_step_contract(proof_steps: list, llm_output: list): # 1. ื‘ื“ื™ืงืช ื›ืžื•ืช ืฆืขื“ื™ื (ื—ื•ื‘ื” ื”ืชืืžื” ืžืœืื”) if len(proof_steps) != len(llm_output): return False, "PEDAGOGICAL_STEP_MISMATCH" # 2. ืื™ืžื•ืช ื–ื”ื•ืช ื”ืฉืœื‘ื™ื (Step ID Binding) for step_rule, step_llm in zip(proof_steps, llm_output): if step_rule.get("step_id") != step_llm.get("step_id"): return False, "STEP_ID_VIOLATION" return True, None def build_standard_response( sections=None, final_answer="", teacher_summary="ืกื™ื™ืžื ื• ืืช ืคืชืจื•ืŸ ื”ืชืจื’ื™ืœ.", graph_base64=None, audio_base64=None, logic_error=False, response_type="standard", strategy_card=None, visual_context=None, error_type=None ): """ Standardize the output format for all responses. """ # ๐Ÿงน Firewall 3: Sterilization if logic_error: # If there's an error, final_answer MUST just be the error message. # We strip away any sections to prevent hallucinated data from reaching the UI. sections = [] response = { "final_answer": final_answer, "teacher_summary": teacher_summary, "sections": sections or [], "graph_base64": graph_base64, "audio_base64": audio_base64, "logic_error": logic_error, "type": response_type, "strategy_card": strategy_card, "visual_context": visual_context } if error_type: response["error_type"] = error_type logger.info(f"๐Ÿ—๏ธ [TRACE] FINAL JSON OUT: {response}") # Changed final_response to response return response def build_structured_projection(llm_commentaries, sympy_steps): """ืžืžื–ื’ ื”ืกื‘ืจื™ื ืžื™ืœื•ืœื™ื™ื ืขื ื”ืœื•ื— ื”ืžืชืžื˜ื™ ืœืœื ืžื’ืข ื™ื“ ืื“ื (V4.2.7)""" structured_response = [] # Ensure we don't exceed the number of available commentaries for i, step in enumerate(sympy_steps): commentary = llm_commentaries[i] if i < len(llm_commentaries) else "ื ื‘ืฆืข ืืช ื”ืฉืœื‘ ื”ื‘ื." # Determine artifact type (basic heuristic for now) artifact_type = "equation" if "table" in str(step.math_content).lower() or "|" in str(step.math_content): artifact_type = "table" structured_response.append({ "step_id": i + 1, "step_number": i + 1, # Backward compatibility "explanation_text": commentary, # ื”ื“ื™ื‘ื•ืจ ืฉืœ ื”ืžื•ืจื” "content_mixed": commentary, # Backward compatibility "math_artifact": { "type": artifact_type, "latex": step.math_content, "table_data": "" # For future expansion }, "block_math": step.math_content # Backward compatibility }) return structured_response logger = logging.getLogger(__name__) def select_best_anchor(candidates: list[str]) -> str: """ื‘ื—ื™ืจืช ื”ื’ืจืกื” ื”ืืจื•ื›ื” ื‘ื™ื•ืชืจ (ืžื ื™ื— ืฉืœืžื•ืช ืžืชืžื˜ื™ืช)""" if not candidates: return "" return max(candidates, key=len) def normalize_latex_for_sympy(expr: str) -> str: # ื”ืžืจืช ื ื’ื–ืจื•ืช ืœืคื•ืจืžื˜ ืฉ-SymPy ืžื‘ื™ืŸ ื‘ืฆื•ืจื” ื“ื™ื ืžื™ืช expr = re.sub(r"([a-zA-Z])'\((.*?)\)", r"Derivative(\1(\2), x)", expr) expr = expr.replace("\\", "") return expr def verify_math_consistency(anchor_latex: str, final_result_latex: str): """ V4.7 Hardened: Handles equations with '=' and never fails silently. """ try: def clean_and_parse(latex_str): # ื ื™ืงื•ื™ ื‘ืกื™ืกื™ ื•ื”ืžืจืช ื ื’ื–ืจื•ืช clean_str = normalize_latex_for_sympy(latex_str).replace('{', '(').replace('}', ')') # ื˜ื™ืคื•ืœ ื‘ืžืฉื•ื•ืื•ืช: ื”ืขื‘ืจืช ืื’ืคื™ื parts = clean_str.split('=') if len(parts) == 2: return sp.sympify(parts[0]) - sp.sympify(parts[1]) return sp.sympify(parts[0]) a = clean_and_parse(anchor_latex) b = clean_and_parse(final_result_latex) is_identical = bool(sp.simplify(sp.expand(a - b)) == 0) return is_identical, (1.0 if is_identical else 0.6) except Exception as e: logger.error(f"โŒ Verification crashed on input: {e}") # BREAKING FIX: ื—ื•ื‘ื” ืœื”ื—ื–ื™ืจ False ื‘ืžืงืจื” ืฉืœ ืงืจื™ืกื”! return False, 0.5 from dotenv import load_dotenv load_dotenv() # Load .env BEFORE genai.configure() import google.generativeai as genai from smart_solver import SmartSolver from gibberish_detector import validate_and_fix_solution import gibberish_detector # For fix_gibberish_smart import visuals # V231.12: Import smart architecture modules from strategy_manager import StrategyManager from pedagogical_builder import build_pedagogical_response, sanitize_math_text import cost_tracker # V231.26: Log usage from audio_generator import generate_teacher_audio # V261.5: Teacher TTS # V1.1: Math Safety Lock Modules import math_intent_detector import curriculum_engine import strategy_policy_engine from proof_graph import ProofGraph, ProofStep, validate_pedagogical_legality from math_sanitizer import ProductionMathSanitizer from pedagogical_builder import build_pedagogical_response, sanitize_math_text, merge_and_verify_explanations, LLMSchemaError # V4.0: Curriculum Oracle # curriculum_engine is already imported above, no need to re-import # import curriculum_engine # V231.14: Import problem understanding import problem_understanding try: from json_repair import repair_json except: repair_json = lambda x: x def safe_json_loads(raw_text: str) -> dict: """V1.0: Delegates to canonical safe_extract_json with LaTeX shield.""" return safe_extract_json(raw_text, caller="ORCHESTRATOR_SAFE_LOADS") from dataclasses import dataclass from smart_solver import ActionContext @dataclass class PipelineContext: grade: str grade_num: int topic: str math_input: str confidence: float category: str = "GENERAL" original_text: str = "" # V4.2.15: For intent-based gating sub_question_text: str = "" # V7.3: Per-question routing context class BuddyOrchestrator: def handle_fallback(self, context: PipelineContext): """V4.2.3: Safe fallback for solver failures.""" print(f"๐Ÿ”„ [FALLBACK] Handling solver failure for grade {context.grade_num}") from types import SimpleNamespace return SimpleNamespace(success=False) def __init__(self): print("โœ… ๐ŸŸข [BIT-LOG: ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” V273.0] - SMART CLASSIFICATION + FAST PATH") genai.configure(api_key=os.environ.get("GOOGLE_API_KEY", "")) # V8.6.1: Force Strict JSON Output to prevent Markdown/Preamble leakage self.model = genai.GenerativeModel( model_name=GEMINI_MODEL, generation_config={"response_mime_type": "application/json"} ) self.vision_model = genai.GenerativeModel( model_name=GEMINI_MODEL, generation_config={"response_mime_type": "application/json"} ) self.smart_solver = SmartSolver() # No model parameter needed # V231.12: Initialize strategy manager self.strategy_manager = StrategyManager(self.model) self._last_ocr_confidence = 1.0 # Default confidence (V3.1.2) print("๐ŸŽฏ [BIT-LOG] StrategyManager initialized") # ===================== V273.0: SMART QUESTION CLASSIFICATION ===================== def _quick_classify(self, problem_text: str) -> ProcessingStrategy: """ V5.8.0: Deterministic classification returning strict ProcessingStrategy """ # ---------- MULTI PART / COMPLEX STRUCTURE ---------- if re.search(r'[ืื‘ื’ื“ื”ื•][\.\)\:\s]', problem_text) or re.search(r'ืกืขื™ืฃ\s*[ืื‘ื’ื“ื”ื•]', problem_text): return ProcessingStrategy.STRICT_SYMBOLIC complex_keywords = [ 'ื—ืงื•ืจ', 'ื—ืงื™ืจืช', 'ื”ื•ื›ื—', 'ื”ื•ื›ื™ื—', 'ืžืงื•ื ื’ื™ืื•ืžื˜ืจื™', 'ื”ืจืื” ื›ื™', 'ื”ืจืื™ ื›ื™', 'ื ืชื•ื ื” ืคื•ื ืงืฆื™ื”', 'ื ืชื•ืŸ ืžืฉื•ืœืฉ' ] if any(kw in problem_text for kw in complex_keywords): return ProcessingStrategy.STRICT_SYMBOLIC # ---------- PURE MATH EXPRESSION ---------- math_only = re.sub(r'[\u0590-\u05FF\s]', '', problem_text) if len(problem_text) < 80 and len(math_only) > len(problem_text) * 0.4: return ProcessingStrategy.SIMPLE_ARITHMETIC # ---------- SHORT CALCULATION ---------- simple_keywords = [ 'ื—ืฉื‘', 'ื—ืฉื‘ื™', 'ืคืฉื˜', 'ืคืฉื˜ื™', 'ืžื”ื•', 'ืžื”ื™', 'ื›ืžื”', 'ืžืฆื', 'ืžืฆืื™', 'ืคืชื•ืจ', 'ืคืชืจื™' ] if len(problem_text) < 150 and any(kw in problem_text for kw in simple_keywords): if not any(kw in problem_text for kw in ['ื•ืœื›ืŸ', 'ืœืคื™ื›ืš', 'ื”ืกื‘ืจ', 'ื ืžืง']): return ProcessingStrategy.SIMPLE_ARITHMETIC # ---------- DEFAULT ---------- return ProcessingStrategy.HEURISTIC_DEDUCTION async def _llm_classify(self, problem_text: str) -> dict: """ V273.0: ืกื™ื•ื•ื’ ืขื LLM - ืœืžืงืจื™ื ืœื ื‘ืจื•ืจื™ื """ prompt = f""" ืกื•ื•ื’ ืืช ื”ืฉืืœื” ื”ืžืชืžื˜ื™ืช ื”ื‘ืื”. ื”ื—ื–ืจ JSON ื‘ืœื‘ื“. ืฉืืœื”: "{problem_text[:500]}" ืงื˜ื’ื•ืจื™ื•ืช: - SIMPLE = ื—ื™ืฉื•ื‘ ื‘ื•ื“ื“, ืชืฉื•ื‘ื” ืื—ืช, ื‘ืœื™ ืกืขื™ืคื™ื (ื“ื•ื’ืžื”: "ื—ืฉื‘ 3+5", "ืคืฉื˜ ืืช ื”ื‘ื™ื˜ื•ื™ xยฒ-4") - MULTI_PART = ื™ืฉ ืกืขื™ืคื™ื ื,ื‘,ื’ ืื• ืžืกืคืจ ืฉืืœื•ืช ื ืคืจื“ื•ืช - COMPLEX = ื—ืงื™ืจืช ืคื•ื ืงืฆื™ื”, ื”ื•ื›ื—ื”, ื’ื™ืื•ืžื˜ืจื™ื” ืžื•ืจื›ื‘ืช, ื‘ืขื™ื” ืขื ื›ืžื” ืฉืœื‘ื™ื JSON: {{ "complexity": "SIMPLE" / "MULTI_PART" / "COMPLEX", "num_parts": ืžืกืคืจ (1 ืื ืคืฉื•ื˜, 2-6 ืื ื™ืฉ ืกืขื™ืคื™ื), "reason": "ื”ืกื‘ืจ ืงืฆืจ ืžืื•ื“" }} """ try: res = await asyncio.wait_for( self.model.generate_content_async( prompt, generation_config={"temperature": 0.0} ), timeout=8.0 ) cost_tracker.log_api_usage(res.usage_metadata, "CLASSIFY_QUESTION") match = re.search(r'\{.*\}', res.text, re.DOTALL) if match: data = safe_json_loads(match.group()) data["confidence"] = "HIGH" data["source"] = "LLM" print(f"๐Ÿท๏ธ [CLASSIFY] LLM result: {data}") return data except Exception as e: print(f"โš ๏ธ [CLASSIFY] LLM failed: {e}") # Fallback - assume complex to be safe return {"complexity": "COMPLEX", "num_parts": 1, "confidence": "LOW", "source": "FALLBACK"} async def _classify_question(self, problem_text: str) -> dict: """ V273.0: ืกื™ื•ื•ื’ ืžืฉื•ืœื‘ - ืžื”ื™ืจ ืงื•ื“ื, LLM ืจืง ืื ืฆืจื™ืš """ print(f"๐Ÿท๏ธ [CLASSIFY] Analyzing: {problem_text[:60]}...") # Step 1: Quick classification (no LLM) quick_result = self._quick_classify(problem_text) if quick_result["confidence"] == "HIGH": print(f"๐Ÿท๏ธ [CLASSIFY] Quick result: {quick_result['complexity']} ({quick_result['source']})") return quick_result if quick_result["confidence"] == "MEDIUM": # Medium confidence - use it but log print(f"๐Ÿท๏ธ [CLASSIFY] Medium confidence: {quick_result['complexity']} ({quick_result['source']})") return quick_result # Step 2: Need LLM classification print(f"๐Ÿท๏ธ [CLASSIFY] Needs LLM classification...") return await self._llm_classify(problem_text) # ===================== V273.0: FAST PATH FOR SIMPLE QUESTIONS ===================== async def _quick_solve( self, problem_text: str, grade: str, student_name: str, image_data: bytes = None, ambiguity_warning: bool = False ) -> dict: """ V273.0: ืคืชืจื•ืŸ ืžื”ื™ืจ ืœืฉืืœื•ืช ืคืฉื•ื˜ื•ืช - ืงืจื™ืื” ืื—ืช ืœ-LLM """ print(f"โšก [FAST PATH] Solving simple question...") prompt = f""" ืืชื” "ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื”" - ืžื•ืจื” ืคืจื˜ื™ืช ื—ืžื” ื•ืžืงืฆื•ืขื™ืช. ืคืชื•ืจ ืืช ื”ืฉืืœื” ื”ื‘ืื” ืขื‘ื•ืจ {student_name} (ื›ื™ืชื” {grade}): "{problem_text}" ื”ื ื—ื™ื•ืช ืงืจื™ื˜ื™ื•ืช: 1. ืคืชื•ืจ ืฆืขื“ ืื—ืจ ืฆืขื“ ื‘ืขื–ืจืช ื”ืื•ื‘ื™ื™ืงื˜ `ctx` ื”ืงื™ื™ื ื‘ืœื‘ื“. 2. **ืืกื•ืจ ื‘ืฉื•ื ืคื ื™ื ื•ืื•ืคืŸ ืœื›ืชื•ื‘ ื”ื’ื“ืจื•ืช ืฉืœ ืžื—ืœืงื•ืช (class), ืคื•ื ืงืฆื™ื•ืช (def) ืื• ื™ื‘ื•ืื™ื (import).** 3. ื”ืฉืชืžืฉ ื‘- `ctx.explain("ื”ืกื‘ืจ")` ืœื›ืœ ืฉืœื‘ ืžื™ืœื•ืœื™. 4. ื”ืฉืชืžืฉ ื‘- `ctx.declare_equation("ืชื™ืื•ืจ", ctx.Eq(x, 5))` ืœืžืฉื•ื•ืื•ืช. 5. ืกื™ื™ื ื‘- `ctx.finish("ืชืฉื•ื‘ื” ืกื•ืคื™ืช ื‘-LaTeX", "ืกื™ื›ื•ื ืžื•ืจื”")`. 6. ื”ื—ื–ืจ ืืš ื•ืจืง ื‘ืœื•ืง ืงื•ื“ ืคื™ื™ืชื•ืŸ ื ืงื™ ื‘ืชื•ืš ```python. ื“ื•ื’ืžื” ืœืžื‘ื ื” ื”ืงื•ื“ ื”ืจืฆื•ื™: ```python ctx.explain("ืจืืฉื™ืช ื ื—ื‘ืจ ืืช ื”ืžืกืคืจื™ื.") ctx.declare_equation("ืคืขื•ืœืช ื”ื—ื™ื‘ื•ืจ", ctx.Eq(2 + 2, 4)) ctx.finish("$$ 4 $$", "ืžืขื•ืœื”! ื”ื’ืขื ื• ืœืชื•ืฆืื”.") ``` """ try: import asyncio if image_data: # Use vision model res = await asyncio.wait_for( asyncio.to_thread( self.vision_model.generate_content, [ prompt, {"mime_type": "image/png", "data": image_data} ] ), timeout=30.0 ) else: res = await asyncio.wait_for( asyncio.to_thread(self.model.generate_content, prompt), timeout=30.0 ) cost_tracker.log_api_usage(res.usage_metadata, "FAST_SOLVE") import math_engine python_match = re.search(r'```python(.*?)```', res.text, re.DOTALL) if python_match: python_code = python_match.group(1).strip() result = math_engine.run_llm_code(python_code) if result["success"]: print(f"โšก [FAST PATH] Python Math Engine execution successful!") return await self._format_quick_response(result, student_name) else: print(f"โŒ [FAST PATH] Math Engine execution error: {result.get('error')}") except Exception as e: print(f"โŒ [FAST PATH] Error: {e}") # Fallback to full pipeline print(f"โš ๏ธ [FAST PATH] Falling back to full pipeline...") return None async def _format_quick_response(self, data: dict, student_name: str) -> dict: """ V273.0: ื”ืžืจืช ืชืฉื•ื‘ื” ืžื”ื™ืจื” ืœืคื•ืจืžื˜ ื”ืกื˜ื ื“ืจื˜ื™ ืฉืœ ื”ืืคืœื™ืงืฆื™ื” """ steps = data.get("steps", []) final_answer = data.get("final_answer", "") teacher_summary = data.get("teacher_summary", "") # Build sections format formatted_steps = [] for step in steps: formatted_steps.append({ "step_number": step.get("step_number", len(formatted_steps) + 1), "title": f"ืฉืœื‘ {step.get('step_number', len(formatted_steps) + 1)}", "content_mixed": step.get("content_mixed", ""), "block_math": step.get("block_math", "") }) sections = [{ "section_title": "ืคืชืจื•ืŸ", "steps": formatted_steps, "section_result": final_answer }] # Generate audio for summary audio_result = None if teacher_summary: teacher_summary = self._scrub_latex_from_text(teacher_summary) teacher_summary = self._sanitize_teacher_response(teacher_summary) try: audio_result = await generate_teacher_audio(teacher_summary) except Exception as e: print(f"๐ŸŽ™๏ธ [FAST PATH] Audio failed: {e}") response = { "sections": sections, "final_answer": final_answer, "teacher_closing": f"ื›ืœ ื”ื›ื‘ื•ื“ {student_name}! ๐ŸŽ‰", "teacher_summary": teacher_summary } if audio_result: if audio_result.startswith("http"): response["audio_url"] = audio_result else: response["audio_base64"] = audio_result return response # ===================== V300: FEATURE-TOGGLED OCR ===================== # OCR_STRIP_MODE=development โ†’ Stitch & Strip (single-pass, HD, structured) # OCR_STRIP_MODE=production โ†’ Legacy Triple-Pass (safe, proven) def _flatten_ocr_payload(self, ocr_data) -> str: """ V9.0.2: Ensures the OCR data is converted into a single, continuous text string regardless of the API response format (JSON string, dict, or list). """ if not ocr_data: return "" # 1. If it's a string, it might be a raw string OR a JSON string if isinstance(ocr_data, str): s = ocr_data.strip() if (s.startswith('[') and s.endswith(']')) or (s.startswith('{') and s.endswith('}')): try: # Attempt to parse if it's a JSON structured string parsed_data = json.loads(s) ocr_data = parsed_data # Pass to dict/list handling below except json.JSONDecodeError: # It's just a regular raw string return s else: return s # 2. If it's a List (This fixes the V9.0.1 bug!) if isinstance(ocr_data, list): # Join all elements with a newline/space, ignoring empty items # V9.0.2 FIX: Handle both list of strings AND list of dicts (Stitch & Strip) parts = [] for item in ocr_data: if isinstance(item, dict): # Handle structured block format: {"content": "...", "type": "..."} content = item.get("content") or item.get("text") or "" if content: parts.append(str(content).strip()) elif item: parts.append(str(item).strip()) if parts: return " \n ".join(parts) return "" # 3. If it's a Dictionary elif isinstance(ocr_data, dict): # Look for a primary text key, otherwise convert the whole dict to string res = ocr_data.get("text") or ocr_data.get("content") if res: return str(res).strip() else: return " \n ".join([f"{k}: {v}" for k, v in ocr_data.items()]) # 4. Ultimate Fallback for any other type return str(ocr_data).strip() async def transcribe_image(self, image_bytes: bytes) -> str: """ V300: Feature-toggled OCR. Returns flat problem_text string. Also stores structured OCR list in self._last_ocr_structured for downstream consumers. """ ocr_mode = os.environ.get("OCR_STRIP_MODE", "production").lower() print(f"๐Ÿ“ธ [BIT-LOG] OCR mode: {ocr_mode.upper()}") # โ”€โ”€ V300 NEW: Stitch & Strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if ocr_mode == "development": debug = True # Always save strips while in DEV print("๐Ÿ“ธ ๐Ÿ”ต [BIT-LOG] Starting OCR (Stitch & Strip V300)...") try: structured, confidence = await ocr_strip_engine.transcribe( image_bytes=image_bytes, vision_model=self.vision_model, debug_mode=debug, ) # Side-channel: store structured list for future use self._last_ocr_structured = structured self._last_ocr_confidence = confidence flat = ocr_strip_engine.flatten_to_text(structured) print(f"๐Ÿ“ธ ๐Ÿ† [BIT-LOG] Stitch & Strip OCR complete โ€” {len(structured)} items, Confidence: {confidence:.2f}") return flat except Exception as e: print(f"๐Ÿ“ธ โŒ [BIT-LOG] Stitch & Strip failed ({e}), falling back to triple-pass") # โ”€โ”€ LEGACY: Triple-Pass โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ print("๐Ÿ“ธ ๐Ÿ”ต [BIT-LOG] Starting OCR (Triple Pass - V231.8)...") self._last_ocr_confidence = 0.85 # Default for legacy mode prompt = prompts.get_transcription_prompt() results = [] # Pass 1: Original Image try: res = await asyncio.wait_for( self.vision_model.generate_content_async( [prompt, {"mime_type": "image/jpeg", "data": image_bytes}], generation_config={"temperature": 0.0} ), timeout=18.0 ) results.append(res.text.strip()) cost_tracker.log_api_usage(res.usage_metadata, "OCR_PASS_1") print(f"๐Ÿ“ธ ๐ŸŸข [BIT-LOG] OCR Pass 1 (Original): {len(results[0])} chars") except Exception as e: print(f"๐Ÿ“ธ ๐ŸŸก [BIT-LOG] OCR Pass 1 failed: {e}") results.append("Error") # Pass 2: Enhanced Image try: enhanced_bytes = self._enhance_image_bytes(image_bytes) res = await asyncio.wait_for( self.vision_model.generate_content_async( [prompt, {"mime_type": "image/jpeg", "data": enhanced_bytes}], generation_config={"temperature": 0.0} ), timeout=18.0 ) results.append(res.text.strip()) cost_tracker.log_api_usage(res.usage_metadata, "OCR_PASS_2") print(f"๐Ÿ“ธ ๐ŸŸข [BIT-LOG] OCR Pass 2 (Enhanced): {len(results[1])} chars") except Exception as e: print(f"๐Ÿ“ธ ๐ŸŸก [BIT-LOG] OCR Pass 2 failed: {e}") results.append("Error") # Pass 3: Retry with reinforced prompt for complex fractions try: retry_prompt = prompt + "\n\nCRITICAL: Pay special attention to complex fractions with powers in denominators, like (x^2-16)^2." res = await asyncio.wait_for( self.vision_model.generate_content_async( [retry_prompt, {"mime_type": "image/jpeg", "data": image_bytes}], generation_config={"temperature": 0.0} ), timeout=18.0 ) results.append(res.text.strip()) cost_tracker.log_api_usage(res.usage_metadata, "OCR_PASS_3") print(f"๐Ÿ“ธ ๐ŸŸข [BIT-LOG] OCR Pass 3 (Retry): {len(results[2])} chars") except Exception as e: print(f"๐Ÿ“ธ ๐ŸŸก [BIT-LOG] OCR Pass 3 failed: {e}") results.append("Error") final_text = self._merge_ocr_results(results) # V9.0.2: Flatten payload (Robust handling of Union[str, list, dict]) final_text = self._flatten_ocr_payload(final_text) # Build minimal structured list for consistency self._last_ocr_structured = [{"type": "text", "content": final_text}] print(f"๐Ÿ“ธ ๐Ÿ† [BIT-LOG] OCR Final (Merged): {len(final_text)} chars") return final_text def _enhance_image_bytes(self, image_bytes: bytes) -> bytes: """V231.4: Attempt high-contrast enhancement. Falls back to original.""" try: from PIL import Image, ImageEnhance import io img = Image.open(io.BytesIO(image_bytes)) # High contrast + sharpness for better OCR img = ImageEnhance.Contrast(img).enhance(2.0) img = ImageEnhance.Sharpness(img).enhance(2.0) buf = io.BytesIO() img.save(buf, format='PNG') return buf.getvalue() except Exception as e: logging.debug(f"โš ๏ธ [BIT-LOG] Image enhancement failed: {e}") # PIL not available or image issue โ€” use original return image_bytes def _merge_ocr_results(self, results: list) -> str: """V231.4: Trust the most mathematically detailed OCR result.""" def _math_complexity(text: str) -> int: """Score how much math content a string has.""" if not text: return 0 score = 0 score += text.count('\\frac') * 3 score += text.count('^') * 2 score += text.count('\\sin') + text.count('\\cos') + text.count('\\tan') score += text.count('\\sqrt') * 2 score += text.count('\\int') * 3 score += text.count('ืกืขื™ืฃ') * 5 # sub-question markers are very valuable score += text.count('ืฉืืœื”') * 5 # top-level headers are very valuable score += len(text) // 50 # length bonus return score valid = [r for r in results if r and r != "Error"] if not valid: return "Error" scored = [(r, _math_complexity(r)) for r in valid] scored.sort(key=lambda x: x[1], reverse=True) winner = scored[0] print(f"๐Ÿ“ธ ๐Ÿ† [BIT-LOG] OCR Winner: score={winner[1]} (of {len(valid)} candidates)") return winner[0] async def _extract_key_data(self, problem_text: str, image_data: bytes = None) -> dict: """V231.14: Phase 1 - Extract specific values with validation and image support.""" for attempt in range(1, 3): # 2 attempts try: print(f"โš“ [BIT-LOG] Data Anchor Extraction (Attempt {attempt}). Image Data: {type(image_data)} {len(image_data) if image_data else 'None'}") prompt = prompts.get_data_extraction_prompt(problem_text) if not prompt: prompt = f"Extract math data from this problem: {problem_text}" content = [prompt] if image_data and isinstance(image_data, bytes): # V316.9: Use canonical dict format for maximum SDK compatibility content.append({"mime_type": "image/png", "data": image_data}) print(f"๐Ÿ“ธ [BIT-LOG] Appended image part (size: {len(image_data)})") res = await asyncio.wait_for( self.model.generate_content_async( content, generation_config={"temperature": 0.0} ), timeout=15.0 # 15s timeout per attempt ) cost_tracker.log_api_usage(res.usage_metadata, "DATA_ANCHOR") match = re.search(r'\{.*\}', res.text, re.DOTALL) if match: data = safe_json_loads(match.group()) # V317.5: Robust JSON Handling - Smart Unification data = unify_data_anchor(data) if isinstance(data, dict): print(f"โš“ [BIT-LOG] Unified Data Anchor: {json.dumps(data, ensure_ascii=False)[:100]}...") # V261.X: Guard against parse-failure sentinel being treated as valid data if data and isinstance(data, dict) and data.get('logic_error') and data.get('error_type') == 'PARSING_FAILURE': print(f"โš ๏ธ [BIT-LOG] Data Anchor JSON parse failed (Attempt {attempt}/2) โ€” skipping sentinel.") continue # โœ… V231.12: Validate extracted data if data and isinstance(data, dict): # Validate function equations - must have '=' sign if 'function_equations' in data: valid_eqs = [] for eq in data['function_equations']: # Must have '=' sign and be longer than just "f(x)" if '=' in eq and len(eq) > 5: valid_eqs.append(eq) else: print(f"โš ๏ธ [BIT-LOG] Invalid equation (no '=' or too short): '{eq}'") data['function_equations'] = valid_eqs # V1.1: Partial Semantic Recovery logic if not valid_eqs: # Determine if recovery is possible (e.g., if there's a point or a simple equation) has_points = len(data.get('points', [])) > 0 has_equations = len(data.get('equations', [])) > 0 if has_points or has_equations: print(f"๐Ÿ”„ [V1.1] Partial Semantic Recovery: No function f(x) but found data. Continuing...") data['anchor_state'] = "PARTIAL_RECOVERABLE" else: print(f"โš ๏ธ [V1.1] Incomplete data: No function or equations. Recapture likely needed.") data['anchor_state'] = "INCOMPLETE" else: data['anchor_state'] = "FULL" if not valid_eqs and 'function_equations' in data: print(f"โš ๏ธ [BIT-LOG] No valid function equations found in attempt {attempt}!") print(f"โš“ [BIT-LOG] Data Anchor (Attempt {attempt}): {json.dumps(data, ensure_ascii=False)}") return data except asyncio.TimeoutError: print(f"โš ๏ธ [BIT-LOG] Data Anchor timeout (Attempt {attempt}/2)") except Exception as e: print(f"โš ๏ธ [BIT-LOG] Data Anchor error (Attempt {attempt}/2): {e}") import traceback traceback.print_exc() print("๐Ÿšจ [BIT-LOG] CRITICAL: Data Anchor extraction failed completely!") return None async def _understand_problem( self, problem_text: str, data_anchor: dict ) -> dict: """ V231.14: Understand problem structure BEFORE solving. Analyzes: - What type of problem is this? - How many sub-questions (ื, ื‘, ื’)? - What is each sub-question asking? - What are the dependencies? Returns understanding JSON. """ print("๐Ÿ“‹ [BIT-LOG] Analyzing problem structure...") try: prompt = problem_understanding.get_problem_understanding_prompt( problem_text, data_anchor ) response = await asyncio.wait_for( self.model.generate_content_async(prompt), timeout=15.0 ) understanding = problem_understanding.parse_understanding(response.text) # Validate if not problem_understanding.validate_understanding(understanding): print("โš ๏ธ [BIT-LOG] Invalid understanding structure, using fallback") return self._create_fallback_understanding(problem_text, data_anchor) # V260.2: Enforce Hard Rules (Locus) understanding = problem_understanding.enforce_locus_rule(understanding, problem_text) print(f"๐Ÿ“‹ [UNDERSTANDING] Type: {understanding['problem_type']}") print(f"๐Ÿ“‹ [UNDERSTANDING] Sub-questions: {len(understanding['sub_questions'])}") for sq in understanding['sub_questions']: print(f" - {sq['id']}: {sq['topic']}") return understanding except Exception as e: print(f"โŒ [BIT-LOG] Understanding failed: {e}") return self._create_fallback_understanding(problem_text, data_anchor) def _create_fallback_understanding(self, problem_text: str, data_anchor: dict) -> dict: """Create simple understanding if analysis fails.""" return { "problem_type": "GENERAL", "main_question": "Solve the problem", "sub_questions": [{ "id": "main", "question": problem_text, "topic": "GENERAL", "requires": [], "expected_output": "solution" }], "solving_order": ["main"], "dependencies": {} } async def _generate_strategy_card(self, problem_text: str, data_anchor: dict) -> dict: """V260.0: Generate high-level strategy card.""" print("๐Ÿงญ [BIT-LOG] Generating Strategy Card...") try: prompt = prompts.get_strategy_card_prompt(problem_text, data_anchor) res = await asyncio.wait_for( self.model.generate_content_async(prompt), timeout=15.0 ) cost_tracker.log_api_usage(res.usage_metadata, "STRATEGY_CARD") match = re.search(r'\{.*\}', res.text, re.DOTALL) if match: data = safe_json_loads(match.group()) print(f"๐Ÿงญ [BIT-LOG] Strategy generated: {data.get('title')}") return self._inject_bidi_markers(data) except Exception as e: print(f"โš ๏ธ [BIT-LOG] Strategy generation failed: {e}") return None async def _generate_visual_context(self, problem_text: str, category: str, image_data: bytes) -> dict: """V300.3: Generate visual description (Sketch). Supports text-only fallback.""" print("GENERATE: [BIT-LOG] Generating Visual Context (Smart Trigger)...") try: prompt = prompts.get_visual_context_prompt(problem_text, category) # V300.3: Multi-modal Support (Handle both Image and Text-Only) if image_data: # Use vision model with image res = await asyncio.wait_for( self.vision_model.generate_content_async([ prompt, {"mime_type": "image/png", "data": image_data} ]), timeout=15.0 ) else: # V300.3: Text-only schematic generation print("INFO: [BIT-LOG] No image provided. Generating sketch from text description.") res = await asyncio.wait_for( self.vision_model.generate_content_async(prompt), timeout=15.0 ) cost_tracker.log_api_usage(res.usage_metadata, "VISUAL_CONTEXT") match = re.search(r'\{.*\}', res.text, re.DOTALL) if match: data = safe_json_loads(match.group()) print(f"SUCCESS: [BIT-LOG] Visual context generated: {data.get('title')}") return self._inject_bidi_markers(data) except Exception as e: print(f"ERROR: [BIT-LOG] Visual context generation failed: {e}") return None def _verify_completeness(self, understanding: dict, solutions: list) -> list: """V260.0: Check if all sub-questions were solved.""" required_ids = [sq['id'] for sq in understanding['sub_questions']] solved_ids = [sol['sub_question_id'] for sol in solutions] missing = [rid for rid in required_ids if rid not in solved_ids] if missing: print(f"๐Ÿ•ต๏ธโ€โ™‚๏ธ [BIT-LOG] Completeness Check: MISSING {missing}") else: print("๐Ÿ•ต๏ธโ€โ™‚๏ธ [BIT-LOG] Completeness Check: PASSED") async def _verify_pedagogical_depth(self, llm_response: dict) -> dict: """V260.1: The Strict Teacher - Verify step depth and clarity.""" print("๐Ÿ‘ฉโ€๐Ÿซ [BIT-LOG] Strict Teacher: Verifying depth...") # Extract steps content for analysis steps_text = "\n".join([f"Step {s.get('step')}: {s.get('content')} | {s.get('block_math')}" for s in llm_response.get("steps", [])]) prompt = f""" YOU ARE A STRICT MATH TEACHER. Review this student's solution. Student's Solution Steps: {steps_text} CRITERIA ("Atomic Algebra"): 1. did the student skip algebraic steps? (e.g. going from 2x=10 directly to x=5 is BAD. Must show /2). 2. Is the Hebrew explanation clear and simple? 3. Are there at least 3-4 steps for a complex problem/locus? 4. IS 'block_math' POPULATED? Steps must show the math in LaTeX! (e.g., don't just say "we calculate", SHOW the calculation). OUTPUT JSON: {{ "approved": true/false, "feedback": "Specific feedback if rejected (e.g., 'Skipped division step', 'Missing LaTeX in block_math')" }} """ try: res = await asyncio.wait_for( self.model.generate_content_async(prompt), timeout=10.0 ) cost_tracker.log_api_usage(res.usage_metadata, "STRICT_TEACHER_VERIFY") match = re.search(r'\{.*\}', res.text, re.DOTALL) if match: data = safe_json_loads(match.group()) print(f"๐Ÿ‘ฉโ€๐Ÿซ [BIT-LOG] Verdict: {'โœ… APPROVED' if data.get('approved') else 'โŒ REJECTED'} ({data.get('feedback')})") # V4.2.4: Final Output Sealing (Zero-Leakage Guard) import math_intent_detector grade_num = math_intent_detector._extract_grade_number(grade) data = seal_pedagogical_output(data, grade_num) return data except Exception as e: print(f"โš ๏ธ [BIT-LOG] Verification failed: {e}") # Default to approved if check fails to avoid blocking return {"approved": True, "feedback": ""} # ===================== V272.0: SMART TEACHER SUMMARY ===================== def _generate_deterministic_summary( self, problem_type: str, topics_text: str, answers_text: str, proof_graph = None ) -> str: """ V4.2 (Behavioral Firewall): Deterministic Template Renderer. Now used ONLY as fallback if LLM summary fails. """ print("๐ŸŽ™๏ธ [V4.2] Generating DETERMINISTIC teacher summary (fallback)...") topic = topics_text if topics_text != "ืžืชืžื˜ื™ืงื” ื›ืœืœื™ืช" else "ื”ืชืจื’ื™ืœ ืฉืฉืœื—ืช" methods = [] if proof_graph: for step in proof_graph.steps: if step.logic_description: methods.append(step.logic_description) methods_text = " ื•- ".join(list(set(methods))[:2]) if methods else "ืฉื™ื˜ื•ืช ืคืชืจื•ืŸ ื‘ืกื™ืกื™ื•ืช" template = ( f"ื”ืชืจื’ื™ืœ ืขืกืง ื‘: {topic}. " f"ื”ืฉืชืžืฉื ื• ื‘ืฉื™ื˜ื•ืช: {methods_text}. " f"ื”ื’ืขื ื• ืœืชืฉื•ื‘ื”: {answers_text}. " f"ื”ื˜ืจื™ืง ืœื–ื›ื•ืจ, ืชืžื™ื“ ื›ื“ืื™ ืœืขื‘ื•ื“ ืฉืœื‘ ืื—ืจ ืฉืœื‘ ื‘ืฆื•ืจื” ืžืกื•ื“ืจืช. " f"ื›ืœ ื”ื›ื‘ื•ื“ ืขืœ ื”ื”ืชืžื“ื”!" ) return template async def _generate_teacher_summary( self, problem_text: str, all_solutions: list, understanding: dict, proof_graph = None, student_name: str = "ืชืœืžื™ื“", student_gender: str = "M" ) -> dict: """ V285.1: LLM-powered pedagogical summary generator. Returns a dict with: topic_summary, key_concepts, formulas_to_remember, tts_speech. Falls back to deterministic template on error. """ # Extract answers and topics for context final_answers = [] topics_used = [] for sol in all_solutions: if 'response' in sol and sol['response']: resp = sol['response'] if isinstance(resp, dict): if resp.get('final_answer'): final_answers.append(resp['final_answer']) if sol.get('topic'): topics_used.append(sol['topic']) answers_text = ", ".join(final_answers[:3]) if final_answers else "ืœื ื ืžืฆืื• ืชืฉื•ื‘ื•ืช" topics_text = ", ".join(set(topics_used)) if topics_used else "ืžืชืžื˜ื™ืงื” ื›ืœืœื™ืช" problem_type = understanding.get('problem_type', 'GENERAL') # Try LLM-powered summary try: print("๐ŸŽ™๏ธ [V285.1] Generating LLM pedagogical summary...") summary_prompt = prompts.get_teacher_summary_prompt( student_name=student_name, student_gender=student_gender ) # Build context for the LLM context = f""" ื”ื‘ืขื™ื”: {problem_text[:300]} ืกื•ื’ ื‘ืขื™ื”: {problem_type} ื ื•ืฉืื™ื: {topics_text} ืชืฉื•ื‘ื•ืช ืกื•ืคื™ื•ืช: {answers_text} """ response = await asyncio.wait_for( self.model.generate_content_async(summary_prompt + "\n\n" + context), timeout=30.0 ) raw_text = response.text.strip() print(f"๐ŸŽ™๏ธ [V285.1] LLM Summary received ({len(raw_text)} chars)") logger.info(f"๐ŸŽ™๏ธ [V285.1] RAW: {raw_text[:300]}") match = re.search(r'\{.*\}', raw_text, re.DOTALL) if not match: raise ValueError("No JSON found in LLM response for teacher summary") summary_data = safe_extract_json(match.group(), caller="TEACHER_SUMMARY") if summary_data.get("error_type") == "PARSING_FAILURE": raise ValueError("JSON parsing failed for teacher summary") # Build structured result result = { "topic_summary": summary_data.get("topic_summary", topics_text), "key_concepts": summary_data.get("key_concepts", []), "formulas_to_remember": summary_data.get("formulas_to_remember", []), "tts_speech": summary_data.get("tts_speech", ""), } # Build display text (for teacher_summary field in response) display_parts = [] if result["topic_summary"]: display_parts.append(f"๐Ÿ“š ื ื•ืฉื: {result['topic_summary']}") if result["key_concepts"]: concepts = "\n".join(f"โ€ข {c}" for c in result["key_concepts"]) display_parts.append(f"๐Ÿ’ก ืชื•ื‘ื ื•ืช ืžืคืชื—:\n{concepts}") if result["formulas_to_remember"]: formulas = "\n".join(f"$${f}$$" for f in result["formulas_to_remember"]) display_parts.append(f"๐Ÿ“ ื ื•ืกื—ืื•ืช ืœื–ื›ื•ืจ:\n{formulas}") result["display_text"] = "\n\n".join(display_parts) print(f"๐ŸŽ™๏ธ โœ… [V285.1] Summary ready: {result['topic_summary']}, {len(result['key_concepts'])} concepts") return result except Exception as e: print(f"๐ŸŽ™๏ธ โš ๏ธ [V285.1] LLM summary failed: {e}. Using deterministic fallback.") fallback_text = self._generate_deterministic_summary(problem_type, topics_text, answers_text, proof_graph) return { "topic_summary": topics_text, "key_concepts": [], "formulas_to_remember": [], "tts_speech": fallback_text, "display_text": fallback_text } def _get_enhanced_fallback_summary(self, problem_type: str, answers: str) -> str: """V272.0: Fallback ืžืฉื•ืคืจ ืœืคื™ ืกื•ื’ ื”ื‘ืขื™ื”""" fallbacks = { "FUNCTION_ANALYSIS": "ืขื‘ืจื ื• ืขืœ ื—ืงื™ืจืช ืคื•ื ืงืฆื™ื” ืžืœืื”! ืžืฆืื ื• ื ืงื•ื“ื•ืช ืงื™ืฆื•ืŸ, ืชื—ื•ืžื™ ืขืœื™ื™ื” ื•ื™ืจื™ื“ื”, ื•ื”ื‘ื ื• ืืช ื”ืชื ื”ื’ื•ืช ื”ืคื•ื ืงืฆื™ื”. ื”ื˜ืจื™ืง ื”ื•ื ืœื–ื›ื•ืจ ืฉื ื’ื–ืจืช ืืคืก ืžืกืžื ืช ื ืงื•ื“ื•ืช ืงื™ืฆื•ืŸ. ื›ืœ ื”ื›ื‘ื•ื“!", "GEOMETRY": "ืคืชืจื ื• ืฉืืœื” ื‘ื’ื™ืื•ืžื˜ืจื™ื” ืื ืœื™ื˜ื™ืช! ื”ืฉืชืžืฉื ื• ื‘ื ื•ืกื—ืื•ืช ืžืจื—ืง ื•ืžืฉื•ื•ืื•ืช ืฉืœ ืฆื•ืจื•ืช ื’ื™ืื•ืžื˜ืจื™ื•ืช. ืชืžื™ื“ ื›ื“ืื™ ืœืฆื™ื™ืจ ืกืงื™ืฆื” ืœืคื ื™ ืฉืžืชื—ื™ืœื™ื ืœื—ืฉื‘. ืžืขื•ืœื”!", "CALCULUS": "ืขื‘ื“ื ื• ืขื ื ื’ื–ืจื•ืช ื•ืื™ื ื˜ื’ืจืœื™ื! ื–ื›ืจื• ืืช ื›ืœืœื™ ื”ื’ื–ื™ืจื” ื”ื‘ืกื™ืกื™ื™ื ื•ืืช ื›ืœืœ ื”ืฉืจืฉืจืช. ื”ืชืจื’ื•ืœ ื”ื•ื ื”ืžืคืชื— ืœื”ืฆืœื—ื” ื‘ื—ื“ื•ื. ื›ืœ ื”ื›ื‘ื•ื“ ืขืœ ื”ื”ืชืžื“ื”!", "ALGEBRA": "ืคืชืจื ื• ืžืฉื•ื•ืื•ืช ื‘ืฆื•ืจื” ืžืกื•ื“ืจืช! ื”ื“ืจืš ื”ื™ื ืœื‘ื•ื“ื“ ืืช ื”ื ืขืœื ืฆืขื“ ืื—ืจ ืฆืขื“. ืชืžื™ื“ ื›ื“ืื™ ืœื‘ื“ื•ืง ืืช ื”ืชืฉื•ื‘ื” ืขืœ ื™ื“ื™ ื”ืฆื‘ื” ื—ื–ืจื”. ื™ื•ืคื™ ืฉืœ ืขื‘ื•ื“ื”!", "TRIGONOMETRY": "ืขื‘ื“ื ื• ืขื ื˜ืจื™ื’ื•ื ื•ืžื˜ืจื™ื”! ื”ืฉืชืžืฉื ื• ื‘ื–ื”ื•ื™ื•ืช ื˜ืจื™ื’ื•ื ื•ืžื˜ืจื™ื•ืช ื•ื‘ืงืฉืจื™ื ื‘ื™ืŸ ื”ืคื•ื ืงืฆื™ื•ืช. ื›ื“ืื™ ืœื–ื›ื•ืจ ืืช ื”ื–ื”ื•ื™ื•ืช ื”ื‘ืกื™ืกื™ื•ืช ื‘ืขืœ ืคื”. ืžืฆื•ื™ืŸ!", "INVESTIGATION": "ืขื‘ืจื ื• ืขืœ ื—ืงื™ืจืช ืคื•ื ืงืฆื™ื” ืžืœืื”! ืžืฆืื ื• ืชื—ื•ื, ื ืงื•ื“ื•ืช ื—ื™ืชื•ืš ืขื ื”ืฆื™ืจื™ื, ื ื’ื–ืจื ื• ื•ืžืฆืื ื• ืงื™ืฆื•ืŸ. ื”ื˜ืจื™ืง ื”ื•ื ืœืขื‘ื•ื“ ื‘ืฉื™ื˜ืชื™ื•ืช - ืฉืœื‘ ืื—ืจื™ ืฉืœื‘. ื›ืœ ื”ื›ื‘ื•ื“!", } base = fallbacks.get(problem_type, "ืคืชืจื ื• ืืช ื”ืชืจื’ื™ืœ ืฆืขื“ ืื—ืจ ืฆืขื“ ื‘ืฆื•ืจื” ืžืกื•ื“ืจืช. ื›ืœ ืฉืœื‘ ื”ื•ื‘ื™ืœ ืœืฉืœื‘ ื”ื‘ื ืขื“ ืฉื”ื’ืขื ื• ืœืชืฉื•ื‘ื”. ื›ืœ ื”ื›ื‘ื•ื“ ืขืœ ื”ื”ืชืžื“ื”!") if answers and answers != "ืœื ื ืžืฆืื• ืชืฉื•ื‘ื•ืช": base += f" ื”ื’ืขื ื• ืœืชืฉื•ื‘ื”: {answers[:50]}." return base # ===================== V285.0: CHECK ME (HOMEWORK VERIFICATION) ===================== async def _check_student_work(self, image_data_list: List[bytes], grade: str, student_name: str, student_gender: str = "M", question_id: str = "q_check"): """ V285.0: Dedicated pipeline for the "Check Me" feature. Sends the raw image to Gemini Vision with the check-me prompt. The LLM acts as a homework checker, NOT a solver. """ print(f"๐Ÿ“ [CHECK-ME] Starting homework verification for {student_name}") # Step 1: Emit WORKING state yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_WORKING, current_section_id="CHECK", payload={"status": "ื”ืžื•ืจื” ื‘ื•ื“ืงืช ืืช ื”ืขื‘ื•ื“ื” ืฉืœืš... ๐Ÿ“"} ) try: # V311.0: Data Slicing Guardrail # First, transcribe and extract the "Absolute Truth" of the problem from the FIRST image image_data = image_data_list[0] print("๐Ÿ“ [CHECK-ME] Step 1.5: Extracting Problem Data (Data Slicing from image_00)...") problem_text = await self.transcribe_image(image_data) data_anchor = await self._extract_key_data(problem_text, image_data=image_data) # Step 2: Build check-me prompt and send to Vision LLM check_prompt = prompts.get_check_me_prompt( grade=grade, student_name=student_name, student_gender=student_gender, data_anchor=data_anchor ) # Prepare images for Gemini Vision vision_content = [check_prompt] for img_bytes in image_data_list: vision_content.append({"mime_type": "image/png", "data": img_bytes}) print(f"๐Ÿ“ [CHECK-ME] Sending {len(image_data_list)} images + check prompt to Vision LLM...") response = await asyncio.wait_for( self.vision_model.generate_content_async(vision_content), timeout=60.0 ) raw_text = response.text.strip() print(f"๐Ÿ“ [CHECK-ME] LLM Response received ({len(raw_text)} chars)") logger.info(f"๐Ÿ“ [CHECK-ME] RAW LLM: {raw_text[:500]}") # Step 3: Parse JSON using the canonical safe_extract_json match = re.search(r'\{.*\}', raw_text, re.DOTALL) if not match: print("๐Ÿ“ โŒ [CHECK-ME] No JSON found in LLM response") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload=build_standard_response( final_answer="ืžืฆื˜ืขืจืช, ืœื ื”ืฆืœื—ืชื™ ืœืคืขื ื— ืืช ื”ื ื™ืชื•ื—. ื ืกื• ืฉื•ื‘? ๐Ÿ”„", logic_error=True, ) ) return check_result = safe_extract_json(match.group(), caller="CHECK_ME") if check_result.get("error_type") == "PARSING_FAILURE": print("๐Ÿ“ โŒ [CHECK-ME] JSON parsing failed, returning error") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload=build_standard_response( final_answer="ืžืฆื˜ืขืจืช, ืœื ื”ืฆืœื—ืชื™ ืœืคืขื ื— ืืช ื”ื ื™ืชื•ื—. ื ืกื• ืฉื•ื‘? ๐Ÿ”„", logic_error=True, ) ) return # Step 4: Extract fields from LLM response verdict = check_result.get("verdict", "has_errors") score = check_result.get("score", 0) mistakes = check_result.get("mistakes", []) encouragement = check_result.get("encouragement", "") problem_identified = check_result.get("problem_identified", "") methodology_ok = check_result.get("methodology_ok", True) methodology_note = check_result.get("methodology_note", "") visual_note = check_result.get("visual_note") correct_answer = check_result.get("correct_final_answer", "") feedback_steps = check_result.get("feedback_steps", []) print(f"๐Ÿ“ โœ… [CHECK-ME] Verdict: {verdict}, Steps: {len(feedback_steps)}") # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # Step 5: Emit STRATEGY_READY โ€” Methodology card # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• strategy_content = f"**ืชืจื’ื™ืœ ืฉื–ื•ื”ื”:** ${problem_identified}$\n\n" if methodology_ok: strategy_content += f"โœ… **ื”ืฉื™ื˜ื” ื ื›ื•ื ื”:** {methodology_note}" if methodology_note else "โœ… **ื”ืฉื™ื˜ื” ืฉื ื‘ื—ืจื” ื ื›ื•ื ื”!**" else: strategy_content += f"โŒ **ื‘ืขื™ื” ื‘ืฉื™ื˜ื”:** {methodology_note}" strategy_card = { "section_title": "ื‘ื“ื™ืงืช ืฉื™ื˜ืช ืคืชืจื•ืŸ ๐Ÿ”", "bullets": [strategy_content] } # Generate TTS audio for encouragement audio_url = None if encouragement: try: tts_text = self._scrub_latex_from_text(encouragement) tts_text = self._sanitize_teacher_response(tts_text) if tts_text: print(f"๐ŸŽ™๏ธ [CHECK-ME] Generating TTS for: {tts_text[:60]}...") audio_url = await generate_teacher_audio(tts_text, output_path=None) except Exception as e: print(f"๐ŸŽ™๏ธ โŒ [CHECK-ME] TTS failed: {e}") strategy_payload = {"strategy_card": strategy_card, "teacher_summary": encouragement} if audio_url: if audio_url.startswith("http"): strategy_payload["audio_url"] = audio_url else: strategy_payload["audio_base64"] = audio_url yield BuddyEvent( question_id=question_id, state=BuddyState.STRATEGY_READY, payload=strategy_payload ) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # Step 6: Emit SECTION_READY per feedback step # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• for fb_step in feedback_steps: step_id = fb_step.get("step_id", 0) student_wrote = fb_step.get("student_wrote", "") is_correct = fb_step.get("is_correct", True) error_desc = fb_step.get("error_description", "") or "" should_be = fb_step.get("should_be", "") or "" # Build rich content for this step if is_correct: title = f"โœ… ืฉืœื‘ {step_id} โ€” ื ื›ื•ืŸ!" content = f"ืžื” ืฉื›ืชื‘ืช: ${student_wrote}$" if student_wrote else "ื ื›ื•ืŸ!" if error_desc: content += f"\n\n{error_desc}" else: title = f"โŒ ืฉืœื‘ {step_id} โ€” ื™ืฉ ื˜ืขื•ืช" content = f"**ืžื” ืฉื›ืชื‘ืช:** ${student_wrote}$\n\n" if student_wrote else "" content += f"**ื”ื˜ืขื•ืช:** {error_desc}" if should_be: content += f"\n\n**ืžื” ืฉืฆืจื™ืš ืœื”ื™ื•ืช:** ${should_be}$" section_data = { "section_title": title, "steps": [{ "step_id": step_id, "content_mixed": content, "block_math": "" }] } yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_READY, current_section_id=f"CHECK_{step_id}", payload=section_data ) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # Step 7: Visual note (if any) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• if visual_note: yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_READY, current_section_id="CHECK_VISUAL", payload={ "section_title": "ื”ืขืจื” ืขืœ ืฉืจื˜ื•ื˜ ๐Ÿ“", "steps": [{ "step_id": 99, "content_mixed": visual_note, "block_math": "" }] } ) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # Step 8: COMPLETE with final answer & Protocol Alignment # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• from pedagogical_builder import sanitize_math_text # V311.0: LaTeX UI Safety safe_correct_answer = sanitize_math_text(correct_answer) if correct_answer else "" if safe_correct_answer and not safe_correct_answer.startswith("$$") and not safe_correct_answer.startswith("$"): safe_correct_answer = f"$${safe_correct_answer}$$" if verdict == "correct": final_answer_text = f"โœ… ื›ืœ ื”ื›ื‘ื•ื“! ื”ืคืชืจื•ืŸ ื ื›ื•ืŸ! {encouragement}" elif verdict == "unreadable": final_answer_text = "๐Ÿ“ธ ืœื ื”ืฆืœื—ืชื™ ืœืงืจื•ื ืืช ื›ืชื‘ ื”ื™ื“. ื ืกื• ืœืฆืœื ืฉื•ื‘ ื‘ืฆื•ืจื” ื‘ืจื•ืจื” ื™ื•ืชืจ." elif verdict == "methodology_error": final_answer_text = f"๐Ÿ“ ื™ืฉ ื‘ืขื™ื” ื‘ืฉื™ื˜ืช ื”ืคืชืจื•ืŸ. {methodology_note}" else: final_answer_text = f"๐Ÿ“ ื”ืชืฉื•ื‘ื” ื”ื ื›ื•ื ื”: {safe_correct_answer}" if safe_correct_answer else encouragement yield BuddyEvent( question_id=question_id, state=BuddyState.COMPLETE, payload={ "final_answer": final_answer_text, "verdict": verdict, "is_correct": verdict == "correct", "score": score, "mistakes": mistakes, "feedback": encouragement, # Protocol Alignment "correct_answer": safe_correct_answer, # Protocol Alignment "problem_identified": problem_identified } ) except asyncio.TimeoutError: print("๐Ÿ“ โŒ [CHECK-ME] LLM timeout (60s)") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload=build_standard_response( final_answer="ื”ื‘ื“ื™ืงื” ืœืงื—ื” ื™ื•ืชืจ ืžื“ื™ ื–ืžืŸ. ื ืกื• ืฉื•ื‘? โฑ๏ธ", logic_error=True, ) ) except Exception as e: logger.exception("CHECK-ME ERROR") print(f"๐Ÿ“ โŒ [CHECK-ME] Error: {e}") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload=build_standard_response( final_answer="ืื•ืคืก! ืžืฉื”ื• ื”ืฉืชื‘ืฉ ื‘ื‘ื“ื™ืงื”. ื ืกื• ืฉื•ื‘? ๐Ÿ”„", logic_error=True, ) ) # ===================== SMART SOLVE ===================== async def smart_solve( self, problem_text: str, data_anchor: dict, grade: str, category: str, student_name: str, student_gender: str = "M", image_data: bytes = None, image_data_list: list = None, ambiguity_warning: bool = False, processing_strategy: ProcessingStrategy = None, question_id: str = "q_default", **kwargs ): """ Workflow: 1. Understand problem structure 2. Solve each sub-question (WITH IMAGE!) 3. Build comprehensive response """ print("๐ŸŽฏ [BIT-LOG] Using SMART SOLVE (V231.15)") uid = kwargs.get('uid') assessment_sent = False try: # Heartbeat: Strategy/Planning yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_WORKING, current_section_id="ื‘ื ื™ื™ืช ืืกื˜ืจื˜ื’ื™ื”", payload={"status": "ื”ืžื•ืจื” ื‘ื•ื ื” ืืกื˜ืจื˜ื’ื™ื” ืœืคืชืจื•ืŸ..."} ) # Step 1: Understand problem structure understanding = await self._understand_problem(problem_text, data_anchor) # V8.9.6: SLICING FAILSAFE (Token Burner Prevention) # If this is a function investigation but the Data Anchor has NO functions, # we must NOT slice into 9 sub-questions. We force Single-Shot Fallback. is_investigation = understanding.get("problem_type") in ["FUNCTION_ANALYSIS", "INVESTIGATION", "CALCULUS"] has_anchor_functions = bool(data_anchor.get("function_equations")) if is_investigation and not has_anchor_functions: print("๐Ÿ›‘ [V8.9.6] Slicing Failsafe Triggered: No functions in Anchor for Investigation. Forcing Single-Shot.") understanding = self._create_fallback_understanding(problem_text, data_anchor) # V260.0: Generate Pedagogical Context (Strategy & Visuals) strategy_card = await self._generate_strategy_card(problem_text, data_anchor) # V8.6.9: Wrap in sections list so solution_screen.dart can correctly parse it yield BuddyEvent( question_id=question_id, state=BuddyState.STRATEGY_READY, payload={"sections": [strategy_card]} if strategy_card else {} ) # V300.3: Smart Visual Triggers (Product Alignment) # The goal: Trigger a sketch if explicitly requested or if it's a visual category without an original image. visual_categories = ["GEOMETRY", "GEOMETRY_ANALYTIC", "TRIGONOMETRY", "INVESTIGATION", "FUNCTIONS", "GEOMETRIC_LOCUS", "CALCULUS"] problem_type = understanding.get("problem_type", "").upper() # Explicit drawing keywords in text (Supports Dutch/Hebrew variations) explicit_drawing_keywords = ["ืฉืจื˜ื˜", "ืกืจื˜ื˜", "ืกืงื™ืฆื”", "ื’ืจืฃ", "ื”ืžื—ืฉื”", "ืฆื™ื™ืจ", "ื›ื™ื•ื•ืŸ", "ืฉืจื˜ื•ื˜", "ืื™ื–ื” ืžืŸ ื”ื’ืจืคื™ื", "ืื™ื–ื” ืžื”ื’ืจืคื™ื", "ืื™ื–ื” ื’ืจืฃ ืžืชืืจ"] is_explicitly_requested = any(keyword in problem_text for keyword in explicit_drawing_keywords) # V300.3: Also check individual sub-questions for explicit requests if not is_explicitly_requested and "sub_questions" in understanding: for sub_q in understanding["sub_questions"]: if any(kw in sub_q.get("question", "") for kw in explicit_drawing_keywords): is_explicitly_requested = True break # Helper sketch: Visual category + no original image provided (or manual request) has_original_image = bool(image_data) needs_helper_sketch = (problem_type in visual_categories) and (not has_original_image) visual_context = None if is_explicitly_requested or needs_helper_sketch: print(f"TRIGGER: [V300.3] Visual Context Triggered: Explicit={is_explicitly_requested}, Helper={needs_helper_sketch}") visual_context = await self._generate_visual_context(problem_text, problem_type or category, image_data) graph_svg = None if visual_context and visual_context.get("latex_input"): print("๐Ÿ“‰ [V290.0] Generating Chalkboard SVG...") try: # V302.0: HOTFIX - SYMPY_PARSE_ERROR guard # If the input is just the d1=d2 placeholder (from prompt instructions), skip it. if "d_1" in visual_context["latex_input"] and "=" in visual_context["latex_input"]: print("๐Ÿ“‰ [V302.0] Skipping placeholder d1=d2 equation to avoid SymPy parse error.") graph_svg = None else: graph_svg = visuals.generate_plot( visual_context["latex_input"], problem_text, visual_context.get("geometric_entities") ) except Exception as e: print(f"โš ๏ธ [V302.0] SYMPY_PARSE_ERROR: Graph generation failed: {e}") graph_svg = None # Step 2: Solve each sub-question all_solutions = [] context = {} # Store results for dependencies total_tokens = 0 # V8.5: Token Firewall Tracking for sub_q in understanding['sub_questions']: print(f"๐Ÿ”„ [SOLVING] Sub-question {sub_q['id']}: {sub_q['topic']}") # V8.5: Emit SECTION_WORKING yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_WORKING, current_section_id=sub_q['id'], payload={"question": sub_q['question']} ) # Solve this sub-question WITH IMAGE # V231.22: FIX - Use the detected problem_type from understanding as the category! # This fixes the bug where FUNCTION_ANALYSIS was treated as GEOMETRY because we passed the stale 'category' arg. effective_category = understanding.get('problem_type', category) # V4.2 PRE-CONSTRAINT LOGIC (Iron Law #1) # 1. Fetch Curriculum Rules FIRST grade_num = math_intent_detector._extract_grade_number(grade) curriculum_rules = curriculum_engine.get_allowed_math_operators(grade, level=kwargs.get('level', '4')) # 2. Detect Intent & Lock Strategy (Iron Law #2 - V4.2.8) intent = math_intent_detector.detect_intent(sub_q['question'], grade_num) strategy_vector = strategy_policy_engine.get_allowed_strategies(intent) intent_contract = math_intent_detector.get_intent_contract(intent, grade_num) # Merge intent_contract with strategy_policy_engine (policy takes precedence) if strategy_vector.get("forbidden"): intent_contract["forbidden_strategies"] = list(set(intent_contract.get("forbidden_strategies", []) + strategy_vector["forbidden"])) # 3. SmartSolver Execution (WITH IMAGE! - Strategy Enforcement) ocr_confidence = self._last_ocr_confidence # 1. ื™ืฆื™ืจืช ืื•ื‘ื™ื™ืงื˜ ื”-Context eqs = data_anchor.get("function_equations", []) joined_eqs = " , ".join(eqs) if eqs else sub_q['question'] # V6 Polish: P0 Canonicalize Math OCR cleaned_math = MathCanonicalizer.preprocess_ocr_string(joined_eqs) context_obj = PipelineContext( grade=grade, grade_num=math_intent_detector._extract_grade_number(grade), topic="GENERAL", math_input=cleaned_math, confidence=ocr_confidence, category=effective_category, original_text=problem_text, sub_question_text=sub_q.get('question', '') # V7.3: scope isolation ) # V6.1 Phase 1: Complexity Estimation & Prompt Specialization complexity_score = CurriculumClassifier.estimate_complexity(cleaned_math) prompt_specialization = CurriculumClassifier.get_prompt_specialization(grade, effective_category) # ==================== V7.2 PRE-FLIGHT CHECKS ==================== # Ticket 1: Build AST metadata (Planner gets this; never gets raw math) ast_metadata = build_ast_metadata(cleaned_math, effective_category) ast_registry = ast_metadata.pop("ast_registry") # Server-side only visual_context = _abstract_visual_context(data_anchor or {}) if visual_context: ast_metadata["visual_context"] = visual_context # Ticket 1: CRS Pre-Flight Gate (BEFORE any LLM call) preflight_risk = CognitiveRiskEngine.calculate_risk_score( cleaned_math, [], retry_count=0, validation_errors=0 ) preflight_crs = preflight_risk["risk_score"] print(f"๐Ÿ”ฌ [PREFLIGHT] Pre-LLM CRS: {preflight_crs} (threshold: 0.7)") if preflight_crs > 0.7: print(f"๐Ÿ›‘ [PREFLIGHT] CRS {preflight_crs} exceeds threshold. Skipping Planner. Entering Hint Mode.") telemetry.emit_crs_block(preflight_crs, data_anchor.get("problem_id", "unknown") if data_anchor else "unknown") telemetry.emit_runtime_outcome("crs_block") yield BuddyEvent( question_id=question_id, state=BuddyState.COMPLETE, payload=build_standard_response( final_answer="ืืคืฉืจ ืœืงื‘ืœ ืจืžื–? ๐ŸŽ“", teacher_summary="ื–ื”ื• ืชืจื’ื™ืœ ืžืืชื’ืจ! ื‘ื•ื ื ืคืจืง ืื•ืชื• ืœื—ืœืงื™ื ืงื˜ื ื™ื ื™ื•ืชืจ ืื• ื ืคืฉื˜ ืืช ื”ื‘ื™ื˜ื•ื™ ื”ืžืจื›ื–ื™.", logic_error=False ) ) return # ==================== END PRE-FLIGHT ==================== # โ”€โ”€ Guard Clause โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if not hasattr(context_obj, "math_input"): raise ValueError("PipelineContext contract violation") import datetime problem_id_tag = data_anchor.get("problem_id", "adhoc_request") if data_anchor else "adhoc_request" # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # THE HYBRID ENGINE (LLM NAVIGATES, POLYGRAPH CONTROLS) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• print("๐Ÿง  [V7.3] Triggering LLM Navigation Mode...") # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• # V8.5: THE MICRO-AGENT SECTION LOOP (RETRY + ESCAPE HATCH) # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• attempts = 0 max_attempts = 2 solved_data = None last_error_context = "" is_degraded = False degraded_reason = None while attempts < max_attempts: attempts += 1 # V8.5: PRE-FLIGHT TOKEN FIREWALL if total_tokens > GLOBAL_TOKEN_LIMIT: print(f"๐Ÿ›‘ [FIREWALL] Token limit reached ({total_tokens} > {GLOBAL_TOKEN_LIMIT}). Aborting solve.") break print(f"๐Ÿง  [V8.5] Solving sub-q {sub_q['id']} (Attempt {attempts}/{max_attempts})") # 1. ื”-LLM ืคื•ืชืจ ื•ืžื ื•ื•ื˜ ืืช ื”ืชืฉื•ื‘ื” ื‘ื”ืชื‘ืกืก ืขืœ ื”ื”ืงืฉืจ (ื•ื”ืฉื’ื™ืื•ืช ื”ืงื•ื“ืžื•ืช ืื ื™ืฉ) solve_prompt = sub_q['question'] if last_error_context: solve_prompt = f"FIX ERROR: {last_error_context}\n\nORIGINAL QUESTION: {solve_prompt}" # V8.6.9: Reset context on retry to reduce token pressure # V310.0: PHYSICAL DATA ISOLATION (The Pink Elephant Fix) # Instead of just prompting, we physically remove values belonging to other sub-questions. all_sub_q_values = [] if "sub_questions" in understanding: for other_q in understanding["sub_questions"]: if other_q.get("specific_values"): all_sub_q_values.extend(other_q["specific_values"]) # Values that are unique to specific sections (not truly global) restricted_pool = set(all_sub_q_values) local_anchor = {**data_anchor} global_values = local_anchor.get("specific_values", []) # Truly global = anchor values minus ANY value identified as section-specific truly_global_values = [v for v in global_values if v not in restricted_pool] # Section-specific = the values identified for THIS sub-question my_specific_values = sub_q.get("specific_values", []) print(f"๐Ÿ“ฆ [V310.0] Data Slicing for {sub_q['id']}: Global={len(truly_global_values)}, Local={len(my_specific_values)}") local_anchor["specific_values"] = list(set(truly_global_values + my_specific_values)) effective_context = {**local_anchor, **context} if attempts == 1 else {**local_anchor} result = await safe_llm_call( lambda: self.strategy_manager.solve_with_strategy( problem_text=solve_prompt, data_anchor=effective_context, grade=grade, image_data=image_data, image_data_list=image_data_list, parent_category=effective_category, student_name=student_name, student_gender=student_gender ), timeout_seconds=30.0 ) if isinstance(result, dict) and result.get("logic_error"): # Fatal LLM/JSON error, don't retry same error last_error_context = "LLM_MALFORMED_JSON_OR_TIMEOUT" continue llm_resp = result.get("llm_response", {}) # V8.5: Token Accumulation usage = llm_resp.get("usage_metadata") if usage: # V8.5.1: usage is now a dict (serialized in strategy_manager) t_count = usage.get('total_token_count', 0) if isinstance(usage, dict) else getattr(usage, 'total_token_count', 0) total_tokens += t_count print(f"๐Ÿ’ฐ [FIREWALL] Cumulative tokens: {total_tokens}") llm_steps = llm_resp if isinstance(llm_resp, list) else llm_resp.get("steps", []) # 2. ื”ืฉืจืช ืฉื•ืœื˜: ื”ืคืขืœืช ื”-Polygraph ืขืœ ื”ืฆืขื“ื™ื ืฉืœ ื”-LLM struct_ok, struct_reason = await MathPolygraph.validate_step_sequence(llm_steps, topic=sub_q.get('topic', 'GENERAL')) poly_ok = struct_ok poly_reason = struct_reason if struct_ok: # V1.3: Also verify algebraic consistency (e.g. A + B = C) alg_ok, alg_reason = await MathPolygraph.verify_algebraic_consistency(llm_steps, topic=sub_q.get('topic', 'GENERAL')) if not alg_ok: poly_ok = False poly_reason = alg_reason if poly_ok: print(f"โœ… [V8.5] Sub-q {sub_q['id']} Validated Successfully (Structure & Algebra)!") solved_data = llm_resp break else: print(f"๐Ÿ›‘ [V8.5] Validation failed on attempt {attempts}/{max_attempts}: {poly_reason}") last_error_context = poly_reason # HOTFIX: ืœื ื–ื•ืจืงื™ื ืชืฉื•ื‘ื” ื˜ื•ื‘ื” ืœืคื—! ืฉื•ืžืจื™ื ืืช ื”ืคืชืจื•ืŸ ืฉืœ ื”-LLM solved_data = llm_resp # ืžืขืงืฃ: ืื ื”ืฉื’ื™ืื” ื”ื™ื ืจืง ื‘ืขื™ื™ืช ืงืจื™ืื” ืฉืœ ืกื™ืžื ื™ื (ืื™-ืฉื•ื•ื™ื•ื ื™ื/ื—ื™ืฆื™ื), ืกื•ืžื›ื™ื ืขืœ ื”-LLM ื•ื™ื•ืฆืื™ื if "SYMPY_PARSE_ERROR" in str(poly_reason): # V280.0 + V310.0: Smart Retry & Soft Fail with JSON Security check # 1. Logic: Only allow bypass if it's NOT the first attempt OR it's a "Soft Fail" case. # 2. Pedagogical: "ืื™ืŸ ืคืชืจื•ืŸ" is allowed. "ืœื ื™ื™ืชื›ืŸ" remains removed. forbidden_words = ["ืกืชื™ืจื” ื‘ื ืชื•ื ื™ื", "ืœื ื”ื’ื™ื•ื ื™", "ืฉื’ื™ืื” ื‘ื—ื™ืฉื•ื‘ ืฉืœื™", "ืื ื™ ืžื–ื”ื” ืกืชื™ืจื”", "ืกืชื™ืจื”", "ื”ืžืฆืื”", "ื˜ืขื•ืช ืฉืœื™", "ืžืชื ืฆืœ"] import json response_text = json.dumps(llm_resp, ensure_ascii=False) has_forbidden = any(word in response_text for word in forbidden_words) if has_forbidden: print(f"๐Ÿ›‘ [ROBUSTNESS] Forbidden word detected in SYMPY_PARSE_ERROR response. Not Trusting LLM.") is_degraded = True degraded_reason = "polygraph_fail_forbidden_words" # Continue to next attempt else: # V317.8 Soft Fail: Treat SymPy Parse Error as a warning immediately to avoid retries on valid LaTeX print(f"๐Ÿ›ก๏ธ [SOFT FAIL] SymPy Parse Error detected (Attempt {attempts}). No forbidden words found. Trusting LLM output for sub-q {sub_q['id']}.") is_degraded = True degraded_reason = "sympy_soft_fail" break # Exit the attempt loop elif attempts == max_attempts: print(f"โš ๏ธ [HOTFIX] Max attempts reached. Forcing LLM response despite Polygraph failure.") is_degraded = True degraded_reason = "max_attempts_polygraph_fail" # 3. Escape Hatch Injection (if failed twice) if not solved_data: is_degraded = True degraded_reason = "polygraph_fail" print(f"๐Ÿ›ก๏ธ [V8.5] ESCAPE HATCH TRIGGERED for sub-q {sub_q['id']}") # V8.5.1: If we have LLM steps but they failed validation, use them anyway # as a degraded fallback instead of the hardcoded d_1 = d_2. if llm_steps: solved_data = { "steps": llm_steps, "final_answer": llm_resp.get("final_answer") if isinstance(llm_resp, dict) else "ื‘ื“ื•ืง ืืช ื”ืฆืขื“ื™ื" } # Add a disclaimer to the first step if possible if solved_data["steps"] and isinstance(solved_data["steps"][0], dict): orig_text = solved_data["steps"][0].get("explanation_text", "") solved_data["steps"][0]["explanation_text"] = f"ื”ืฆื’ื ื• ืืช ื”ืฆืขื“ื™ื ื”ืขื™ืงืจื™ื™ื ื›ื“ื™ ืฉืชื•ื›ืœ ืœืขืงื•ื‘ ืื—ืจื™ ื”ื“ืจืš:\n\n{orig_text}" else: solved_data = { "steps": [ { "step_id": 1, "explanation_text": "ื”ื—ื™ืฉื•ื‘ ื‘ืกืขื™ืฃ ื–ื” ื”ืคืš ืœืžื•ืจื›ื‘ ืžืื•ื“. ื”ืฆื’ื ื• ืืช ื”ืขื™ืงืจื•ืŸ ื”ืžื ื—ื”:", "math_latex": "d_1 = d_2" } ], "final_answer": "ื”ืžืฉืš ืœืคื™ ื”ืฉืœื‘ื™ื" } # 4. Packaging & Yielding # V8.6.7 FIX / V317.5: Only pass the final answer text forward to prevent massive JSON injection in future prompts context[f"result_{sub_q['id']}"] = solved_data.get("final_answer", "No valid answer extracted") if isinstance(solved_data, dict) else "ื”ื•ืฉืœื" # Check for critical failure (No valid answer extracted) # If a section fails completely, we break the loop to avoid cascading failures if context[f"result_{sub_q['id']}"] == "No valid answer extracted": print(f"๐Ÿ›‘ [ABORT] Section {sub_q['id']} failed completely. Aborting orchestration.") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload={"error": "Section failure", "message": "ื—ืœื” ืฉื’ื™ืื” ื‘ืขื™ื‘ื•ื“ ื—ืœืง ืžื”ืฉืืœื”. ืขื•ืฆืจื™ื ื›ื“ื™ ืœืžื ื•ืข ื˜ืขื•ื™ื•ืช."} ) return # AI Assessment Telemetry Extraction if not assessment_sent and isinstance(solved_data, dict) and "assessment" in solved_data: assessment_data = solved_data["assessment"] if uid and assessment_data: try: from analytics import analytics_manager loop = asyncio.get_event_loop() loop.create_task(asyncio.to_thread(analytics_manager.update_weekly_analytics, uid, assessment_data)) print(f"๐Ÿ“Š [ANALYTICS] Triggered background telemetry for {uid}") assessment_sent = True except Exception as e: print(f"โŒ [ANALYTICS] Failed to trigger background task: {e}") response = build_pedagogical_response( effective_category, solved_data, data_anchor, custom_title=f"ืกืขื™ืฃ {sub_q['id']}", processing_strategy=ProcessingStrategy.HEURISTIC_DEDUCTION ) # V8.5: Inject degradation flags into payload if is_degraded: response["is_degraded"] = True response["degraded_reason"] = degraded_reason # V290.0: Inject graph into section payload if available (Early Projection) if graph_svg: response["graph_svg"] = graph_svg response["graph_base64"] = "" # Legacy fallback # Emit SECTION_READY yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_READY, current_section_id=sub_q['id'], payload=response ) all_solutions.append({ "sub_question_id": sub_q['id'], "question": sub_q['question'], "topic": sub_q['topic'], "response": response }) # Step 3: Build multi-part response (V4.2.16 Hotfix) final_response = self._build_multi_part_response( all_solutions, strategy_card=strategy_card, visual_context=visual_context ) # V260.2: FIX MISSING GRAPH (Forensic Finding 2026-02-14) if visual_context: print("๐Ÿ“‰ [BIT-LOG] Generating graph from visual context...") try: # Note: visuals.generate_plot is synchronous in visuals.py V231.10 latex_for_plot = visual_context.get("latex_input", "") if latex_for_plot: # V261.2: Pass Explicit Geometric Entities geometric_entities = visual_context.get("geometric_entities") graph_svg = visuals.generate_plot(latex_for_plot, problem_text, geometric_entities) if graph_svg: final_response["graph_svg"] = graph_svg final_response["graph_base64"] = "" # Legacy fallback print(f"๐Ÿ“‰ [BIT-LOG] SVG Graph generated! ({len(graph_svg)} chars)") else: print("๐Ÿ“‰ [BIT-LOG] Graph generation returned None/Empty") else: print("๐Ÿ“‰ [BIT-LOG] No latex_input in visual context. Skipping graph.") except Exception as e: print(f"โš ๏ธ [BIT-LOG] Graph generation failed: {e}") # ===================== V285.1: SMART TEACHER SUMMARY + TTS ===================== print("๐ŸŽ™๏ธ [BIT-LOG] Starting V285.1 Smart Teacher Summary...") # Generate pedagogical summary using LLM summary_result = await self._generate_teacher_summary( problem_text, all_solutions, understanding, proof_graph=None, student_name=student_name, student_gender=student_gender ) # Extract TTS text (clean, no LaTeX, no emojis) tts_text = summary_result.get("tts_speech", "") if tts_text: tts_text = self._scrub_latex_from_text(tts_text) tts_text = self._sanitize_teacher_response(tts_text) # Generate Audio from TTS text if tts_text: print(f"๐ŸŽ™๏ธ [BIT-LOG] Generating Audio for: {tts_text[:60]}...") try: audio_result = await generate_teacher_audio(tts_text, output_path=None) if audio_result: if audio_result.startswith("http"): final_response["audio_url"] = audio_result print(f"๐ŸŽ™๏ธ โ˜๏ธ [BIT-LOG] Audio uploaded: {audio_result}") else: final_response["audio_base64"] = audio_result print(f"๐ŸŽ™๏ธ ๐Ÿ’ฟ [BIT-LOG] Audio as Base64") else: print("๐ŸŽ™๏ธ โš ๏ธ [BIT-LOG] Audio generation returned None") except Exception as e: print(f"๐ŸŽ™๏ธ โŒ [BIT-LOG] Audio CRASHED: {e}") # Store the structured summary matching Flutter's _buildSummaryCard format # Flutter expects: {audio_pitch, key_concepts, formulas} topic_summary = summary_result.get("topic_summary", "") key_concepts = summary_result.get("key_concepts", []) formulas = summary_result.get("formulas_to_remember", []) # Build the audio_pitch text: topic + TTS speech audio_pitch_parts = [] if topic_summary: audio_pitch_parts.append(f"ื ื•ืฉื ื”ืฉื™ืขื•ืจ: {topic_summary}") if tts_text: audio_pitch_parts.append(tts_text) audio_pitch = " ".join(audio_pitch_parts) if audio_pitch_parts else "" structured_summary = { "audio_pitch": audio_pitch, "key_concepts": key_concepts, "formulas": formulas, } if audio_pitch or key_concepts or formulas: final_response["teacher_summary"] = structured_summary print(f"๐ŸŽ™๏ธ โœ… [V285.1] Structured summary set: topic={topic_summary}, concepts={len(key_concepts)}, formulas={len(formulas)}") else: print("๐ŸŽ™๏ธ [BIT-LOG] No summary data generated") # ===================== END V285.1 TTS BLOCK ===================== # V5.10.0: Save to History if Premium tier = kwargs.get('tier', 'student_basic') # Variable uid is already defined at start of smart_solve print(f"๐Ÿ” [DEBUG HISTORY] UID: {uid}, Received Tier: '{tier}', kwargs keys: {list(kwargs.keys())}") is_premium = tier in ["premium", "admin", "admin_unlimited"] if is_premium and uid: try: # V315.0: Explicit scheduling with loop check loop = asyncio.get_event_loop() loop.create_task(self._save_exercise_history(uid, problem_text, all_solutions)) print(f"๐Ÿ“š [HISTORY] History saving scheduled for {uid}") except Exception as e: print(f"โŒ [HISTORY] Failed to schedule history saving: {e}") # V277.0: FIXED - Yield final solution as a COMPLETE event instead of using return (which is ignored by async generators) yield BuddyEvent( question_id=question_id, state=BuddyState.COMPLETE, payload=final_response ) return except Exception as e: print(f"โŒ [BIT-LOG] Smart solve error: {e}") import traceback traceback.print_exc() # Logic Error Enforced Fallback yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload=build_standard_response( final_answer="ื”ืกื‘ืจ ื–ื” ื”ื•ืกืจ ืขืงื‘ ื—ืจื™ื’ื” ืžื”ื—ื•ื–ื”.", teacher_summary="ื—ืœื” ืฉื’ื™ืื” ื˜ื›ื ื™ืช ื‘ืžืขืจื›ืช ื”ืžืชืžื˜ื™ืช. ืื ื ื ืกื” ืœืฆืœื ืฉื•ื‘.", logic_error=True ) ) return def _build_multi_part_response(self, solutions, strategy_card=None, visual_context=None): """V6 Polish: Aggregator Refactoring - Isolate sections with statuses, remove string concatenation.""" sections = [] all_answers = [] summaries = [] print(f"๐Ÿ“Š [BIT-LOG: AGGREGATOR] Merging {len(solutions)} sub-questions") # 1. Strategy & Visual (V260.0 compat) if strategy_card: strategy_steps = [] if "steps" in strategy_card and isinstance(strategy_card["steps"], list): for i, s_text in enumerate(strategy_card["steps"]): clean_s = sanitize_math_text(s_text) strategy_steps.append({"step_id": i+1, "explanation_text": clean_s, "content_mixed": clean_s, "math_artifact": {"type": "equation", "latex": ""}}) else: clean_s = sanitize_math_text(strategy_card.get("content", "")) strategy_steps.append({"step_id": 0, "explanation_text": clean_s, "content_mixed": clean_s, "math_artifact": {"type": "equation", "latex": ""}}) sections.append({ "section_title": strategy_card.get("title", "ืื™ืš ื ื™ื’ืฉื™ื ืœื–ื”? ๐Ÿงญ"), "status": "SUCCESS", "steps": strategy_steps }) if visual_context: clean_v = sanitize_math_text(visual_context.get("description", "")) sections.append({ "section_title": visual_context.get("title", "ื”ืžื—ืฉื” ื—ื–ื•ืชื™ืช โœ๏ธ"), "status": "SUCCESS", "steps": [{"step_id": 0, "explanation_text": clean_v, "content_mixed": clean_v, "math_artifact": {"type": "equation", "latex": visual_context.get("latex_input", "")}}] }) for sol in solutions: res = sol.get("response", {}) sub_q_status = "SUCCESS" # Default status if isinstance(res, dict): # V6 Polish: Determine status based on flags in response block if res.get("logic_error"): # Check if it was a solver failure (no rule found) or parse error. Default to FAILED_RULE if logic error flagged. sub_q_status = "FAILED_RULE" if "sections" in res and res["sections"]: # Flatten sub-question sections and append status for section in res["sections"]: base_title = section.get('section_title', '') if "ืกืขื™ืฃ" not in base_title: section["section_title"] = f"ืกืขื™ืฃ {sol.get('sub_question_id', '?')} - {base_title}" section["status"] = sub_q_status sections.append(section) else: # If this sub-question failed entirely and has no steps to show sections.append({ "section_title": f"ืกืขื™ืฃ {sol.get('sub_question_id', '?')} - ื”ื•ืคืกืง ืื• ื ื›ืฉืœ", "status": sub_q_status, "steps": [] }) if res.get("teacher_summary") and not res.get("logic_error"): summaries.append(res["teacher_summary"]) response = build_standard_response( sections=sections, final_answer="ื”ืคืชืจื•ืŸ ืœื›ืœืœ ื”ืกืขื™ืคื™ื ืžืคื•ืจื˜ ืœืžื˜ื”.", teacher_summary=summaries[0] if summaries else "ืกื™ื™ืžื ื• ืืช ื”ื ื™ืชื•ื—.", graph_base64=None, audio_base64=None, logic_error=False, response_type="standard", strategy_card=strategy_card, visual_context=visual_context ) print(f"โœ… [BIT-LOG: AGGREGATOR] Response built. Sections: {len(sections)}") return response # ===================== CORE SOLVE ===================== async def solve_problem(self, problem_text, grade, student_name, **kwargs): """ V277.0: Main solve method with BINARY DATA SUPPORT and TUTOR SESSION support. """ uid = kwargs.get('uid') session_id = kwargs.get('session_id') # ===================== V318.0: TUTOR SESSION MODE ===================== if session_id and uid: print(f"๐ŸŽ“ [TUTOR-MODE] Activating Session-Based Dialogue for session: {session_id}") # We yield from the tutor handler event = await self._handle_tutor_session(problem_text, student_name, uid, session_id, **kwargs) yield event return # ===================== END TUTOR SESSION MODE ===================== uid = kwargs.get('uid') image_data_list = kwargs.get('image_data_list') image_data = kwargs.get('image_data') or kwargs.get('image_bytes') if image_data and not image_data_list: image_data_list = [image_data] elif image_data_list and not image_data: image_data = image_data_list[0] # V316.0: CRITICAL - Ensure image_data is explicitly passed in kwargs for the rest of parameters kwargs['image_data'] = image_data kwargs['image_data_list'] = image_data_list question_id = kwargs.get('question_id', f"q_{int(time.time())}") start_time = asyncio.get_event_loop().time() # GLOBAL_TIMEOUT_SEC = 240 # 4 minutes usually # Immediate Heartbeat for UX yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_WORKING, current_section_id="ื ื™ืชื•ื— ืชืžื•ื ื”", payload={"status": "ื”ืžื•ืจื” ืงื•ืจืืช ืืช ื”ืฉืืœื”..."} ) # ===================== V285.0: CHECK ME ROUTING ===================== mode = kwargs.get('mode', 'solve') if mode == 'check' and image_data: print(f"๐Ÿ“ [V285.0] Mode=CHECK detected. Routing to _check_student_work()...") student_gender = kwargs.get('student_gender', 'M') async for event in self._check_student_work( image_data_list=image_data_list, grade=grade, student_name=student_name, student_gender=student_gender, question_id=question_id ): yield event return # ===================== END CHECK ME ROUTING ===================== if image_data_list: print(f"๐Ÿ”ต [BIT-LOG] Starting OCR Pipeline on {len(image_data_list)} images...") ocr_results = [] for i, img in enumerate(image_data_list): print(f"๐Ÿ“ธ [BIT-LOG] Transcribing image {i}...") text = await self.transcribe_image(img) if text: ocr_results.append(text) problem_text = "\n\n".join(ocr_results) image_data = image_data_list[0] # Use first image for main processing logic/anchors logger.info(f"๐Ÿ”Ž [TRACE] RAW OCR TEXT: {problem_text}") student_gender = kwargs.get('student_gender', 'M') print(f"๐Ÿง  [BIT-LOG] Orchestrating for {student_name} (V277.0 - BINARY DATA SUPPORT)") # ๐Ÿงฑ Firewall 1: OCR Early Exit import re ocr_clean = problem_text.strip() if problem_text else "" # ื—ื™ื™ื‘ ืœื”ื›ื™ืœ ืœืคื—ื•ืช ืื•ืช ืื ื’ืœื™ืช ืื—ืช, ืžืกืคืจ, ืื• ืกื™ืžืŸ ืžืชืžื˜ื™ has_math_anchor = bool(re.search(r'[0-9xyzXYZ=+\-\(\)]', ocr_clean)) # V5.7.5: Short Math Bypass (Happy Flow for simple equations) # Often simple equations like $2+2=?$ yield low OCR confidence but are valid. is_short_math = has_math_anchor and len(ocr_clean) < 15 and len(ocr_clean) > 2 # ืžืฆื‘ 3: Hard Stop (Low Confidence) if self._last_ocr_confidence < CONFIDENCE_THRESHOLD_MEDIUM and not is_short_math: print(f"๐Ÿ”ด [V3.1.2] Hard Stop: Confidence {self._last_ocr_confidence:.2f} < {CONFIDENCE_THRESHOLD_MEDIUM}") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload=build_standard_response( final_answer="ื”ืžืขืจื›ืช ืœื ื”ืฆืœื™ื—ื” ืœื–ื”ื•ืช ืืช ื›ืœ ื”ื ืชื•ื ื™ื. ืื ื ืฆืœื ืฉื•ื‘ ื‘ืฆื•ืจื” ื‘ืจื•ืจื” ื™ื•ืชืจ.", logic_error=True, # response_type="error", # error_type="RECAPTURE_REQUIRED" ) ) return # ืžืฆื‘ 2: Soft Recovery (Medium Confidence) ambiguity_warning = self._last_ocr_confidence < CONFIDENCE_THRESHOLD_HIGH if ambiguity_warning: print(f"๐ŸŸก [V3.1.2] Soft Recovery: Confidence {self._last_ocr_confidence:.2f} < {CONFIDENCE_THRESHOLD_HIGH}") # ===================== V5.8.0: STRATEGY RESOLUTION ===================== strategy = self._quick_classify(problem_text) print(f"๐Ÿท๏ธ [BIT-LOG] Strategy: {strategy.value}") # V8.5: Initialize Token Tracking from cost_tracker import CostTracker tokens = CostTracker() # ===================== SIMPLE FAST PATH ===================== if strategy == ProcessingStrategy.SIMPLE_ARITHMETIC: print("โšก [BIT-LOG] Using SIMPLE_ARITHMETIC Fast Path") yield BuddyEvent(question_id=question_id, state=BuddyState.SECTION_WORKING, current_section_id="A", payload={"status": "Solving locally..."}) fast_result = await self._quick_solve( problem_text=problem_text, grade=grade, student_name=student_name, image_data=image_data, ambiguity_warning=ambiguity_warning ) if fast_result: fast_result, _, _ = validate_and_fix_solution(fast_result) # Quick Polygraph check _poly_steps = collect_all_steps(fast_result) _poly_ok, _ = await MathPolygraph.validate_step_sequence(_poly_steps, topic=str(strategy.value)) if _poly_ok: yield BuddyEvent( question_id=question_id, state=BuddyState.SECTION_READY, current_section_id="A", payload=build_standard_response(**fast_result) ) yield BuddyEvent(question_id=question_id, state=BuddyState.COMPLETE, payload={}) return # ===================== FULL STREAMING PIPELINE ===================== print(f"๐ŸŽฏ [BIT-LOG] Using Streaming Pipeline Strategy: {strategy.value}") # V316.0: image_data is already hydrated at the top of solve_problem. # This block is now redundant but kept for safety if someone moves things. if image_data is None and image_data_list: image_data = image_data_list[0] print("๐Ÿ“ธ [BIT-LOG] Using first image from list for Data Anchor phase. (Redundant Check)") data_anchor = await self._extract_key_data(problem_text, image_data=image_data) or {} # V8.9.2: SEPARATE VALIDATOR PASS (Single Source of Truth) if image_data and data_anchor: print("๐Ÿ›ก๏ธ [V8.9.2] Starting Data Anchor Validation Pass...") data_anchor = await self._validate_anchor(data_anchor, image_data, problem_text) # V1.0: GEOMETRIC SANITY CHECK (Ground Truth Injection) # Runs BEFORE the LLM to verify algebraic consistency of the extracted data. # Injects 'verified_facts' and 'geometry_warnings' into the anchor. try: _geo_anchor, _geo_prompt_block = run_geometric_sanity(data_anchor) if _geo_anchor.verified_facts or _geo_anchor.warnings: data_anchor["_verified_facts"] = _geo_anchor.verified_facts data_anchor["_geometry_warnings"] = _geo_anchor.warnings data_anchor["_geo_prompt_block"] = _geo_prompt_block print(f"๐Ÿ”ฌ [GEO-SANITY] Injected {len(_geo_anchor.verified_facts)} fact(s), " f"{len(_geo_anchor.warnings)} warning(s) into data_anchor.") except Exception as _geo_err: logging.warning(f"[GEO-SANITY] Non-fatal error: {_geo_err}") # Iterate through the streaming smart_solve # V5.10.2: Remove keys already passed explicitly to avoid TypeError collision for _key in ['student_gender', 'image_data', 'image_data_list', 'image_bytes', 'mode', 'question_id', 'user_note']: kwargs.pop(_key, None) try: async for event in self.smart_solve( problem_text=problem_text, data_anchor=data_anchor, grade=grade, category="investigation", # simplified student_name=student_name, student_gender=student_gender, processing_strategy=strategy, image_data=image_data, image_data_list=image_data_list, ambiguity_warning=ambiguity_warning, question_id=question_id, **kwargs ): # ๐Ÿ›ก๏ธ Global Guard 1: Token Controller # Check current tokens from global tracking if possible, or per call # (For now we rely on the fact that each LLM call logs to CostTracker) # ๐Ÿ›ก๏ธ Global Guard 2: Timeout elapsed = asyncio.get_event_loop().time() - start_time if elapsed > GLOBAL_TIMEOUT_SEC: print(f"๐Ÿ›‘ [V8.5] Global Timeout ({elapsed:.1f}s) reached. Cutting stream.") yield BuddyEvent( question_id=question_id, state=BuddyState.COMPLETE, payload={"warning": "Solving timed out. Partial results shown."} ) return yield event # Final Event - No longer needed as smart_solve now yields the final COMPLETE event # yield BuddyEvent(question_id=question_id, state=BuddyState.COMPLETE, payload={}) pass except Exception as e: logger.exception("STREAMING ERROR") yield BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload={"error": str(e), "message": "ืื•ืคืก! ืžืฉื”ื• ื”ืฉืชื‘ืฉ ื‘ืขื™ื‘ื•ื“ ื”ืฉืืœื”."} ) # V260.5: General Q&A ("Ask the Teacher") async def ask_question(self, context_data, question, student_name): """ Answers a specific student question based on the problem context. context_data: The full solution JSON (or relevant parts). question: The student's question. """ # Serialize context for prompt context_str = json.dumps(context_data, ensure_ascii=False) prompt = f""" ืชืคืงื™ื“: ืžื•ืจื” ืคืจื˜ื™ืช ืœืžืชืžื˜ื™ืงื” (ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” V260.5). ื”ืชืœืžื™ื“ {student_name} ืฉื•ืืœ ืฉืืœื” ืขืœ ื”ืคืชืจื•ืŸ ืฉืงื™ื‘ืœ. ื”ืงืฉืจ (ื”ื ืชื•ื ื™ื ื•ื”ืคืชืจื•ืŸ ืฉื›ื‘ืจ ื ื•ืฆืจ): {context_str} ื”ืฉืืœื” ืฉืœ ื”ืชืœืžื™ื“: "{question}" ื”ื ื—ื™ื•ืช: 1. ืขื ื” ืœืขื ื™ื™ืŸ, ื‘ืฆื•ืจื” ืงืฆืจื” ื•ืžืžื•ืงื“ืช. 2. ื”ืฉืชืžืฉ ื‘ื˜ื•ืŸ ืžืขื•ื“ื“ ื•ื—ื (ื›ืžื• "ื”ื ืกื™ืš/ื”ื ืกื™ื›ื”"). 3. ืื ื”ืฉืืœื” ืงืฉื•ืจื” ืœื ื•ืกื—ื”, ื›ืชื•ื‘ ืื•ืชื” ื‘-LaTeX ืชืงื ื™ (ื‘ืชื•ืš $...$). 4. ืื ื”ืชืœืžื™ื“ ืœื ื”ื‘ื™ืŸ ืžืฉื”ื•, ื”ืกื‘ืจ ืœื• ื‘ืžื™ืœื™ื ืคืฉื•ื˜ื•ืช ื™ื•ืชืจ. 5. ืงืจื™ื˜ื™: ืืชื” ื‘ืžืžืฉืง ืฆ'ืื˜ ื ื˜ื•ืœ ืงื‘ืฆื™ื ืžืฆื•ืจืคื™ื! ืœืขื•ืœื ืืœ ืชื’ื™ื“ "ื”ื”ืกื‘ืจ ื”ืžืœื ื‘ืคืชืจื•ืŸ ื”ืžืฆื•ืจืฃ" ืื• ืœืฉืœื•ื— ืืช ื”ืชืœืžื™ื“ ืœืžืงื•ืจ ื—ื™ืฉื•ื‘ ื—ื™ืฆื•ื ื™. ืขืœื™ืš ืœืกืคืง ืืช **ื›ืœ ื”ื—ื™ืฉื•ื‘ ื•ื”ื”ืกื‘ืจ ื”ืžืชืžื˜ื™ ื”ืžืœื ื•ื”ืžืคื•ืจื˜** ื™ืฉื™ืจื•ืช ื‘ืชื•ืš ื”ื˜ืงืกื˜ ืฉืœ ื”ืชืฉื•ื‘ื” ืฉืœืš (ืฉื“ื” "answer"). ื—ื•ื‘ื”: ื”ื—ื–ืจ JSON ื‘ืœื‘ื“! {{ "answer": "ื”ืชืฉื•ื‘ื” ื”ืžืœืื”, ื›ื•ืœืœ ื›ืœ ืฆืขื“ื™ ื”ื—ื™ืฉื•ื‘ ื•ื”ื”ืกื‘ืจ ื”ืžืชืžื˜ื™...", "follow_up_suggestion": "ืฉืืœื” ื ื•ืกืคืช ืฉื”ืชืœืžื™ื“ ื™ื›ื•ืœ ืœืฉืื•ืœ (ืื•ืคืฆื™ื•ื ืœื™)" }} """ try: res = await asyncio.wait_for( self.model.generate_content_async(prompt), timeout=30.0 ) # Extract JSON match = re.search(r'\{.*\}', res.text, re.DOTALL) if not match: raise ValueError("No JSON found in LLM response for ask_question") data = safe_extract_json(match.group(), "ask_question") # Sanitize math in answer if "answer" in data: from pedagogical_builder import sanitize_math_text data["answer"] = sanitize_math_text(str(data["answer"])) return build_standard_response( teacher_summary=data.get("answer", ""), sections=[{ "section_title": "ืชืฉื•ื‘ื” ืœืฉืืœื”", "steps": [{ "step_id": 1, "explanation_text": data.get("answer", ""), "math_artifact": {"type": "equation", "latex": "", "table_data": ""} }] }], final_answer=data.get("follow_up_suggestion", "") ) error_msg = "ืœื ื”ืฆืœื—ืชื™ ืœื”ื‘ื™ืŸ ืืช ื”ืฉืืœื”, ื ืกื” ืœื ืกื— ืฉื•ื‘? ๐Ÿค”" return build_standard_response( teacher_summary=error_msg, logic_error=True ) except Exception as e: print(f"๐Ÿ”ฅ [BIT-LOG] Ask Question error: {e}") return build_standard_response( teacher_summary="ืื•ืคืก, ื ืชืงืœืชื™ ื‘ื‘ืขื™ื” ืงื˜ื ื”. ื‘ื•ื ื ื ืกื” ืฉื•ื‘! ๐Ÿ˜…", logic_error=True ) async def explain_specific_step(self, context, step_text, student_name): """V231.4: Step explanation with LaTeX shield.""" prompt = f""" ืชืคืงื™ื“: ืžื•ืจื” ืคืจื˜ื™ืช ืœืžืชืžื˜ื™ืงื” (ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” V231.4). ื”ืชืœืžื™ื“ {student_name} ื‘ื™ืงืฉ ื”ืกื‘ืจ ื ื•ืกืฃ ืขืœ ืฆืขื“ ืกืคืฆื™ืคื™. ื”ื”ืงืฉืจ: {context} ื”ืฆืขื“ ืฉืฆืจื™ืš ื”ืกื‘ืจ: {step_text} ื”ืกื‘ืจ ืืช ื”ืฆืขื“ ืžืืคืก, ื›ืื™ืœื• ื”ืชืœืžื™ื“ ืจื•ืื” ืืช ื”ื ื•ืฉื ื‘ืคืขื ื”ืจืืฉื•ื ื”. ื”ืฉืชืžืฉ ื‘ืคื•ืจืžื˜: "ื”ืกื‘ืจ ื‘ืขื‘ืจื™ืช :: $ื ื•ืกื—ื”$" ื—ื•ื‘ื”: ื›ืœ ืคืงื•ื“ืช LaTeX ื—ื™ื™ื‘ืช ืœื”ืชื—ื™ืœ ื‘-\\ (ืœืžืฉืœ: \\frac, \\cdot, \\sqrt). ื”ื—ื–ืจ JSON: {{ "explanation": "...", "example": "..." }} """ try: res = await asyncio.wait_for( self.model.generate_content_async(prompt), timeout=30.0 ) match = re.search(r'\{.*\}', res.text, re.DOTALL) if not match: raise ValueError("No JSON found in LLM response for explain_step") data = safe_extract_json(match.group(), "explain_step") data = self._scrub_placeholders(data) data = self._enhance_latex_v2(data) if data and ("explanation" in data or "example" in data): return data return {"explanation": "ืœื ื”ืฆืœื—ืชื™ ืœื™ื™ืฆืจ ื”ืกื‘ืจ.", "example": ""} except Exception as e: print(f"๐Ÿ”ฅ [BIT-LOG] Explain error: {e}") return {"explanation": "ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” ื ืชืงืœ ื‘ืงื•ืฉื™. ื ืกื” ืฉื•ื‘.", "example": ""} async def _handle_tutor_session(self, problem_text, student_name, uid, session_id, **kwargs): """ V318.0: Logic for Phase A - Contextual Memory & Tutor Dialogue. """ question_id = kwargs.get('question_id', f"tutor_{int(time.time())}") image_data_list = kwargs.get('image_data_list') or [] grade = kwargs.get('grade', "ื›ื™ืชื” ื™'") # 1. Fetch History (Last 10 messages) history_docs = firebase_manager.get_chat_history(uid, session_id, limit=10) # 2. Map history to Gemini format history_contents = [] for doc in history_docs: role = "user" if doc.get('role') == 'user' else "model" history_contents.append({ "role": role, "parts": [doc.get('content', '')] }) # 3. System Instruction system_instruction = f"""ืืช ืžื•ืจื” ืคืจื˜ื™ืช ืœืžืชืžื˜ื™ืงื”. ื”ืžื˜ืจื” ืฉืœืš ื”ื™ื ืœื ื”ืœ ื“ื™ืืœื•ื’ ืœืžื™ื“ื” ืขื ื”ืชืœืžื™ื“ {student_name} (ื›ื™ืชื” {grade}). ื—ื•ืงื™ ื”ืฉื™ื—ื”: 1. ืื ื–ื• ืชื—ื™ืœืช ืฉื™ื—ื” (ื”ื™ืกื˜ื•ืจื™ื” ืจื™ืงื”): ืืœ ืชืคืชืจื™ ื›ืœื•ื. ืฉืืœื™ ืืช ื”ืชืœืžื™ื“: 'ื”ื™ื™ {student_name}! ืžื” ืื ื—ื ื• ืœื•ืžื“ื™ื ื”ื™ื•ื ื‘ื›ื™ืชื”?' ื•ื—ื›ื™ ืœืชืฉื•ื‘ื”. 2. ื‘ืจื’ืข ืฉื–ื•ื”ื” ื ื•ืฉื: ืฉืžืจื™ ืื•ืชื• ื‘ื–ื™ื›ืจื•ืŸ ื”ืฉื™ื—ื” ื•ื”ืชื™ื™ื—ืก ืืœื™ื• ื‘ื”ืžืฉืš. 3. ื‘ื‘ืงืฉืช 'ื‘ื“ืงื™ ืœื™' (ื›ืฉื™ืฉ ืชืžื•ื ื•ืช ืฉืœ ืคืชืจื•ืŸ): ื ืชื—ื™ ืืช ื”ืชืžื•ื ื” ืžื•ืœ ื”ืฉืืœื”. ืื ื™ืฉ ื˜ืขื•ืช, ืฆื™ื™ื ื™ ืืช ื”ืฉื•ืจื” ื•ืืช ืกื•ื’ ื”ื˜ืขื•ืช (ืกื™ืžื ื™ื, ื—ื•ืงื™ ื—ื–ืงื•ืช ื•ื›ื•'). ืืœ ืชืชืงื ื™ ืžื™ื“, ืชื ื™ ืจืžื–. 4. ื”ืฉืชืžืฉ ื‘ื˜ื•ืŸ ืžืขื•ื“ื“, ื—ื ื•ื‘ื’ื•ื‘ื” ื”ืขื™ื ื™ื™ื. 5. ื›ืœ ืคืœื˜ ื—ื™ื™ื‘ ืœื”ื™ื•ืช ื‘ืคื•ืจืžื˜ JSON ืชืงื™ืŸ ืœืคื™ ื”ืกื›ื™ืžื” ื”ืžื•ื’ื“ืจืช. """ # 4. Construct Current Input current_input_parts = [] if problem_text: current_input_parts.append(problem_text) for img_bytes in image_data_list: current_input_parts.append({ "mime_type": "image/jpeg", "data": img_bytes }) if not current_input_parts: current_input_parts.append("(ื”ืชืœืžื™ื“ ื”ืฆื˜ืจืฃ ืœืฉื™ื—ื”)") # 5. Gemini Call with Structured Output try: # We use a temporary chat session to include history chat = self.model.start_chat(history=history_contents) # V318: Enforce JSON mode and schema generation_config = genai.GenerationConfig( response_mime_type="application/json", response_schema=TutorResponseSchema, temperature=0.7 ) # Prepend system instruction as a message if model doesn't support separate system_instruction in start_chat # Actually, Gemini 2.0 Flash supports system_instruction in the model constructor or in GenerateContent. # Here we'll append it to the prompt for maximum compatibility with existing self.model. full_prompt = f"[SYSTEM_INSTRUCTION]\n{system_instruction}\n\n[USER_INPUT]\n" res = await self.model.generate_content_async( contents=history_contents + [{"role": "user", "parts": [full_prompt] + current_input_parts}], generation_config=generation_config ) # 6. Parse & Save data = json.loads(res.text) student_message = data.get("student_message", "") analytics = data.get("internal_analytics", {}) # Determine intent for metadata intent = analytics.get("intent", "CHAT") mastery_score = analytics.get("mastery_score", 0) topic = analytics.get("topic", "General") # V318.0: TRIGGER CHALLENGE GENERATOR if Mastery > 70 if mastery_score > 70 and intent in ["SOLVE", "CHECK"]: logger.info(f"๐Ÿ† [TUTOR-MODE] High Mastery detected ({mastery_score}). Triggering Challenge Generator.") from exercise_generator import exercise_generator # Fire and forget (Background Task) asyncio.create_task(exercise_generator.generate_challenge(problem_text or "(ืžืขื‘ื“ ืชืžื•ื ื”)", topic, uid)) # Append the challenge offer to the tutor's message offer_text = "\n\nื›ืœ ื”ื›ื‘ื•ื“! ื”ื›ื ืชื™ ืœืš ืชืจื’ื™ืœ ืืชื’ืจ ื“ื•ืžื” ื›ื“ื™ ืœื•ื•ื“ื ืฉื–ื” ื™ื•ืฉื‘ ื˜ื•ื‘. ืจื•ืฆื” ืœื ืกื•ืช? ๐Ÿ’ช" student_message += offer_text # Save User Message (if there was text/images) if problem_text or image_data_list: firebase_manager.save_chat_message( uid, session_id, "user", problem_text or "(ืชืžื•ื ื”)", metadata={"intent": intent} ) # Save Assistant Message firebase_manager.save_chat_message( uid, session_id, "assistant", student_message, metadata=analytics ) # 7. Map to BuddyEvent for UI return BuddyEvent( question_id=question_id, state=BuddyState.COMPLETE, payload=build_standard_response( teacher_summary=student_message, final_answer="" ) ) except Exception as e: logger.error(f"โŒ [TUTOR-SESSION] Error: {e}") return BuddyEvent( question_id=question_id, state=BuddyState.ERROR, payload={"error": str(e), "message": "ื”ืžื•ืจื” ื ืชืงืœื” ื‘ื‘ืขื™ื” ื‘ื–ื™ื›ืจื•ืŸ ืฉืœ ื”ืฉื™ื—ื”."} ) # ===================== PIPELINE METHODS ===================== def _get_v231_pedagogic_block(self): """V231.4: Pedagogic instructions appended to every prompt.""" return r""" \n๐Ÿ›‘ ืžืฉื™ืžื”: ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” (ืกื˜ื ื“ืจื˜ ืคืจื™ืžื™ื•ื V231.4) ๐Ÿ›‘ 1. ื”ืกื‘ืจ ืžืืคืก: ื›ืœ ืฉืœื‘ ืžืœื•ื•ื” ื‘ื”ืกื‘ืจ ืžื™ืœื•ืœื™ ืขืฉื™ืจ. 2. ื‘ื˜ื™ื—ื•ืช ืœื˜ืš: ื—ื•ื‘ื” ืœื”ืฉืชืžืฉ ื‘- \\frac{}{} ื•ืœื ื‘- frac. 3. ื ื™ืงื™ื•ืŸ: ืืœ ืชืฉืื™ืจ ืคืœื™ื™ืกื”ื•ืœื“ืจื™ื ื›ืžื• $formula$ ืื• $math$ ื‘ื˜ืงืกื˜. 4. ื›ืœ ืคืงื•ื“ืช LaTeX ื—ื™ื™ื‘ืช ืœื”ืชื—ื™ืœ ื‘-\\. ืœืžืฉืœ: \\frac, \\cdot, \\sqrt, \\pi. 5. Hebrew or GEOMETRIC NOTATION (e.g., \angle, ^\circ, \triangle) inside $$ is FORBIDDEN. It breaks the app. Use inline math in content_mixed for dimensions/angles. 6. NEVER write \\left or \\right. Use ( ) instead. 7. Each math expression MUST be on its OWN LINE. 8. NEVER use \\ne (conflicts with newline). USE \\neq instead. 9. STRATEGY CARD: The first section "ืื™ืš ื ื™ื’ืฉื™ื ืœื–ื”?" must contain ONLY hints and thinking strategy โ€” NO final answers! 10. OCR SACRED: You MUST solve the EXACT function from the image. If OCR says x^2/(x^2-4), solve THAT. Do NOT invent a different function. 11. MATH SEPARATION (CRITICAL): block_math is for PURE ALGEBRA ONLY. NO \angle, \triangle, ^\circ, \text{}, \quad, or 'and' allowed in block_math. Use inline math `$...$` in content_mixed for dimensions, labels, or explanatory math. 12. PEDAGOGICAL HIGHLIGHTING: When performing a substitution, showing a change in sign, taking a derivative, or highlighting a key transition in a calculation, use `\color{color_name}{...}` (e.g., `\color{red}{x^2}`, `\color{blue}{+4}`) to visually highlight the element that changed or needs the student's focus. Ensure you only wrap valid Math in the color tag. """ def _scrub_placeholders(self, data): """โœ… V231.0: ืžืกื™ืจ ืคืœื™ื™ืกื”ื•ืœื“ืจื™ื ืจื™ืงื™ื ืฉ-Gemini ืžืฉืื™ืจ ื‘ื˜ืขื•ืช""" if isinstance(data, str): result = data result = re.sub(r'\$\s*formula\d*\s*\$', '', result) result = re.sub(r'\$\s*math\d*\s*\$', '', result) result = result.replace('[math]', '') result = result.replace('[formula]', '') result = re.sub(r'::\s*$', '', result, flags=re.MULTILINE) return result.strip() if isinstance(data, dict): return {k: self._scrub_placeholders(v) for k, v in data.items()} if isinstance(data, list): return [self._scrub_placeholders(i) for i in data] return data def _enhance_latex_v2(self, data): """โœ… V231.2: ืžืชืงืŸ ืœื•ื›ืกื ื™ื ืจืง ื‘ืชื•ืš $...$ โ€” ืœื ื ื•ื’ืข ื‘ืขื‘ืจื™ืช!""" if isinstance(data, str): def _fix_content(content): content = re.sub( r'(? 0: first_sec = sections[0] title = first_sec.get('section_title', '') if 'ื ื™ื’ืฉื™ื' in title or 'ืืกื˜ืจื˜ื’ื™ื”' in title or '๐Ÿงญ' in title: # This is the strategy card โ€” scrub Hebrew side steps = first_sec.get('steps', []) for step in steps: if 'content_mixed' in step: val = step['content_mixed'] if '::' in val and '$' in val: parts = val.split('::', 1) hebrew = re.sub(r'\$[^$]*\$', '', parts[0]).strip() step['content_mixed'] = f"{hebrew} :: {parts[1].strip()}" else: # Pure Hebrew strategy line โ€” strip any accidental LaTeX val = re.sub(r'\\[a-zA-Z]+', '', val) val = val.replace('$', '').replace('{', '').replace('}', '') step['content_mixed'] = re.sub(r'\s+', ' ', val).strip() return data return data def _inject_bidi_markers(self, data): """โœ… V231.11: Smart BiDi with RLM markers (Right-to-Left Mark).""" # RLM (\u200F) marks direction without reversing text # Unlike RLE (\u202B) which was causing full text reversal if isinstance(data, str): # Check if string contains Hebrew has_hebrew = bool(re.search(r'[\u0590-\u05FF]', data)) if has_hebrew: return '\u200F' + data return data if isinstance(data, dict): return {k: self._inject_bidi_markers(v) for k, v in data.items()} if isinstance(data, list): return [self._inject_bidi_markers(i) for i in data] return data def _purge_double_dollars(self, data): """โœ… V275.3: Remove orphan double-dollars, fix quadruple dollars, and unwrap Hebrew-in-math.""" if isinstance(data, str): # Fix quadruple dollars $$$$ -> $$ globally before processing data = re.sub(r'\${3,}', '$$', data) # Remove empty math blocks: $$ $$ or $$$$ data = re.sub(r'\$\$\s*\$\$', '', data) # V275.4: Unwrap Hebrew paragraphs from $$...$$ blocks (mirror text fix) def _unwrap_hebrew(match): content = match.group(1).strip() hebrew_chars = len(re.findall(r'[\u0590-\u05FF]', content)) total_chars = len(content.replace(' ', '')) if total_chars == 0: return '' # Heuristic 1: >25% Hebrew = text paragraph (lowered from 40%) if hebrew_chars / total_chars > 0.25 and hebrew_chars > 8: return content # Heuristic 2: 3+ consecutive Hebrew words if re.search(r'[\u0590-\u05FF]+\s+[\u0590-\u05FF]+\s+[\u0590-\u05FF]+', content): return content return match.group(0) data = re.sub(r'\$\$(.+?)\$\$', _unwrap_hebrew, data, flags=re.DOTALL) # Preserve display math: $$...$$ # Remove orphan $$: "text $$" or "$$ text" # Strategy: Protect display math blocks, purge orphans, restore protected protected = [] def protect_display_math(match): protected.append(match.group(0)) return f"__DISPLAY_MATH_{len(protected)-1}__" # Protect $$...$$ blocks (non-greedy match) result = re.sub(r'\$\$[^$]+?\$\$', protect_display_math, data) # Now purge orphan $$ (multiple consecutive $) result = re.sub(r'\$\$+', '$', result) # Restore protected blocks for i, block in enumerate(protected): result = result.replace(f"__DISPLAY_MATH_{i}__", block) if result != data: print(f"๐Ÿ’ฒ [BIT-LOG] Purged orphan $$ and fixed quadruple $: '{data[:50]}...' โ†’ '{result[:50]}...'") return result if isinstance(data, dict): return {k: self._purge_double_dollars(v) for k, v in data.items()} if isinstance(data, list): return [self._purge_double_dollars(i) for i in data] return data def _error_response(self): return build_standard_response( final_answer="ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” ื ืชืงืœ ื‘ืงื•ืฉื™. ื ืกื” ืฉื•ื‘.", teacher_summary="ื”ืžื•ืจื” ืœืžืชืžื˜ื™ืงื” ืžืชื ืฆืœ, ืืš ื—ืœื” ืฉื’ื™ืื” ืœื ืฆืคื•ื™ื”.", logic_error=True, response_type="error" ) def _sanitize_for_sympy(self, expr: str) -> str: """โœ… V231.4: Robust SymPy sanitizer. Converts \\frac{4}{3}x -> (4)/(3)*x, handles all implicit multiplication.""" s = str(expr) # Step 0: Pre-algebraic cleanup (Remove UI/Geometric/Text notation) s = re.sub(r'\\color\{.*?\}(?:\{.*?\})?', '', s) # Aggressively strip color tags s = re.sub(r'\\text\{[^{}]*\}', '', s) s = re.sub(r'\^(\{\\circ\}|\\circ)', '', s) s = re.sub(r'\\(angle|triangle|quad|qquad)', '', s) s = re.sub(r'\band\b', '', s) s = re.sub(r'\s*,\s*', ' ', s) # Remove commas with surrounding space # Step 1: Convert LaTeX fractions PROPERLY: \frac{a}{b} -> (a)/(b) while '\\frac' in s: s = re.sub(r'\\frac\s*\{([^{}]*)\}\{([^{}]*)\}', r'(\1)/(\2)', s) if '\\frac' in s and '{' not in s: s = s.replace('\\frac', '') # Step 2: Convert other LaTeX commands s = s.replace('\\cdot', '*').replace('\\times', '*') s = s.replace('\\pi', 'pi').replace('\\sqrt', 'sqrt') s = s.replace('\\sin', 'sin').replace('\\cos', 'cos').replace('\\tan', 'tan') s = s.replace('\\ln', 'ln').replace('\\log', 'log') s = s.replace('\\left', '').replace('\\right', '') # Step 3: Convert remaining braces to parens s = s.replace('{', '(').replace('}', ')') # Step 4: Convert ^ to ** s = s.replace('^', '**') # Step 5: Implicit multiplication (run multiple passes) for _ in range(3): # Multiple passes catch nested cases s = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', s) # 4x โ†’ 4*x, 2( โ†’ 2*( s = re.sub(r'\)([a-zA-Z0-9(])', r')*\1', s) # )x โ†’ )*x, )( โ†’ )*( s = re.sub(r'([a-zA-Z])(? str: """V281.1: Aggressively strips non-printable characters from math blocks.""" if not text: return "" # Remove Tabs, Newlines, and multiple spaces which break KaTeX s = text.replace('\t', ' ').replace('\n', ' ').replace('\r', ' ') s = re.sub(r'\s+', ' ', s) return s.strip() async def _validate_anchor(self, data_anchor: dict, image_data: bytes, problem_text: str = "") -> dict: """V8.9.2: Single Source of Truth Validator pass.""" try: from prompts import get_anchor_validation_prompt from utils.safe_json import safe_extract_json prompt = get_anchor_validation_prompt(data_anchor) # Using current model which supports Vision response = await self.model.generate_content_async( [ {"mime_type": "image/jpeg", "data": image_data}, prompt ] ) match = re.search(r'\{.*\}', response.text, re.DOTALL) if match: clean_anchor = safe_extract_json(match.group(), "anchor_validator") if clean_anchor: print(f"๐Ÿ›ก๏ธ โœ… [V8.9.2] Anchor Validated: {len(clean_anchor.get('function_equations', []))} equations found.") return clean_anchor # V8.9.6: If validator returns garbage, Fallback to raw OCR print("โš ๏ธ [V8.9.6] Validator returned invalid JSON. Falling back to raw OCR.") return {"raw_ocr_text": problem_text} except Exception as e: print(f"โš ๏ธ [V8.9.2] Anchor Validation failed: {e}. Falling back to raw OCR.") return {"raw_ocr_text": problem_text} async def _save_exercise_history(self, uid: str, question: str, solutions: list): """V317.0: Saves sanitized exercise history with clean titles.""" try: db = firebase_manager.get_db() if not db or not uid: return def generate_clean_title(ocr_raw_text): try: # ืžื ืงื” JSON ืื ืงื™ื™ื if isinstance(ocr_raw_text, str) and ocr_raw_text.strip().startswith('{'): match = re.search(r'\{.*\}', ocr_raw_text, re.DOTALL) if match: data = safe_json_loads(match.group()) text = data.get('text', '') else: text = str(ocr_raw_text) else: text = str(ocr_raw_text) # ื ื™ืงื•ื™ LaTeX ื•ืกื™ืžื ื™ื ื˜ื›ื ื™ื™ื ืžืชืงื“ื # 1. ื”ืกืจืช ื‘ืœื•ืงื™ื ืฉืœ ืžืชืžื˜ื™ืงื” $...$ text = re.sub(r'\$.*?\$', '', text) # 2. ื”ืกืจืช ืคืงื•ื“ื•ืช LaTeX ื ืคื•ืฆื•ืช (ืœืžืฉืœ \frac{...}{...}) text = re.sub(r'\\[a-zA-Z]+', '', text) # 3. ื”ืกืจืช ืกื•ื’ืจื™ื™ื ืžืกื•ืœืกืœื™ื ื•ืžืจื•ื‘ืขื™ื text = re.sub(r'[\{\}\[\]]', '', text) # 4. ื ื™ืงื•ื™ ืกื™ืžื ื™ื ืžืชืžื˜ื™ื™ื ืฉืืจื™ืชื™ื™ื text = re.sub(r'[\^_*=+\-/|]', '', text) # ื—ื™ืชื•ืš ืœ-6 ืžื™ืœื™ื ืจืืฉื•ื ื•ืช words = text.split() if not words: return "ืชืจื’ื™ืœ ื‘ืžืชืžื˜ื™ืงื”" title = " ".join(words[:6]) if len(words) > 6: title += "..." return title.replace('\n', ' ').strip() except Exception as e: logging.debug(f"โš ๏ธ [BIT-LOG] Title generation failed: {e}") return "ืชืจื’ื™ืœ ื‘ืžืชืžื˜ื™ืงื”" # Flatten solution steps into a single string solution_text_parts = [] for sol in solutions: res = sol.get("response", {}) if "sections" in res: for section in res["sections"]: title = section.get("section_title", "") solution_text_parts.append(f"### {title}") for step in section.get("steps", []): exp = step.get("explanation_text", "") or "" math = step.get("math_artifact", {}).get("latex", "") or "" if not math: math = step.get("block_math", "") or "" solution_text_parts.append(str(exp)) if math: math = self._deep_sanitize_math(str(math)) solution_text_parts.append(f"$${math}$$") solution_text_parts.append("---") full_solution = "\n\n".join(solution_text_parts) from firebase_admin import firestore import datetime # Save to history collection with clean title history_ref = db.collection('users').document(uid).collection('history').document() history_ref.set({ "original_question_text": question, "display_title": generate_clean_title(question), "solution_steps_text": full_solution, "timestamp": firestore.SERVER_TIMESTAMP }) print(f"โœ… [HISTORY] Successfully saved exercise to DB: users/{uid}/history") except Exception as e: print(f"โŒ [HISTORY] Error saving history: {e}") import traceback traceback.print_exc() # ===================== EXISTING PIPELINE METHODS ===================== def _smart_minify(self, data): """V226.1+ Line-based minifier that preserves newlines around Math blocks.""" if isinstance(data, str): clean = data.replace('\r\n', '\n').replace('\r', '\n') token = "###NEWLINE_TOKEN###" clean = re.sub(r'\n\s*\n', token, clean) lines = clean.split('\n') result = [] for i, line in enumerate(lines): stripped = line.strip() if not stripped: continue should_keep = False if stripped.endswith('$$') or stripped.startswith('$$'): should_keep = True if i + 1 < len(lines): next_stripped = lines[i+1].strip() if next_stripped.startswith('$$') or next_stripped.startswith('*') or next_stripped.startswith('-'): should_keep = True if next_stripped.startswith('**'): should_keep = True result.append(stripped) if should_keep: result.append('\n') else: result.append(' ') final_text = "".join(result).replace(token, '\n\n') final_text = re.sub(r' +', ' ', final_text) return final_text.strip() elif isinstance(data, dict): return {k: self._smart_minify(v) for k, v in data.items() if v is not None} elif isinstance(data, list): return [self._smart_minify(i) for i in data if i is not None] return data def _sanitize_teacher_response(self, text: str) -> str: """ V275.2: ANTI-CHATTER GUARD ๐Ÿ›ก๏ธ Removes ENTIRE SENTENCES containing apologetic/self-correction language. V261.7: PIPELINE SYNC - Now also runs sanitize_math_text! """ if not text: return "" # V275.4: Expanded chatter phrases - catches self-correction monologue from LLM chatter_words = ( r"(oops|sorry|mistake|apologize|let me correct|my bad" r"|ืื•ืคืก|ืกืœื™ื—ื”|ื˜ืขื•ืช|ืžืชื ืฆืœ|ืชื™ืงื•ืŸ|ืฉื’ื™ืชื™|ื˜ืขื™ืชื™|ืžืฆื˜ืขืจ" r"|ื‘ื•ื ื ืชืงืŸ|ืจื’ืข|ืฉื™ื ืœื‘ ืœื˜ืขื•ืช|ื”ื™ื™ืชื” ื˜ืขื•ืช|ื ืคืœื” ื˜ืขื•ืช|ืชื™ืงื•ืŸ ื˜ืขื•ืช|ื‘ื—ื™ืฉื•ื‘ ื”ืงื•ื“ื" # V275.4: New patterns from investigation question logs r"|ื˜ืขื•ืช ื—ืฉื™ื‘ื”|ื‘ื•ื ื ื ืกื” ืฉื•ื‘|ืกื•ืฃ ืกื•ืฃ ื”ื‘ื ืชื™|ืขืœ ื”ื‘ืœื‘ื•ืœ" r"|ื–ื” ืœื ืขื•ื‘ื“|ืžื” ืขื•ืฉื™ื|ื–ื” ืœื ื˜ื•ื‘|ื–ื” ืœื ื ื›ื•ืŸ|ืžื” ืงื•ืจื” ืคื”" r"|ื‘ื•ื ื ื—ืฉื•ื‘ ืขืœ ื–ื”|ืฉืœื™ ื”ื™ื™ืชื”|ื‘ื•ื ื ื—ื–ื•ืจ ืื—ื•ืจื”|ื‘ื•ื ื ืชื—ื™ืœ ืžื”ืชื—ืœื”" r"|ืื ื™ ืงืฆืช ืžื‘ื•ืœื‘ืœ|ืขืœ ื›ืœ ื”ื˜ืขื•ื™ื•ืช|ื ื•ืกืคืช|ืงื•ื ืกืคื˜ื•ืืœื™ืช|ื‘ืฉืืœื”" r"|wait|ืจื’ืข|let me think|hold on)" ) # Regex to match the ENTIRE SENTENCE containing one of the chatter phrases # Matches from previous period/newline to the next period/newline chatter_sentence_pattern = r"(?i)\s*[^.!?\n]*?(?:" + chatter_words + r")[^.!?\n]*?[.!?]?" cleaned = text cleaned = re.sub(chatter_sentence_pattern, "", cleaned) # V261.7: Global Math Sanitization (Fixes mangled ( x )^2 etc.) cleaned = sanitize_math_text(cleaned) return cleaned.strip() def _scrub_latex_from_text(self, data): """โœ… V261.3: Aggressively scrub LaTeX commands from Hebrew text.""" if isinstance(data, str): # 1. Protect math blocks $...$ and $$...$$ protected = [] def protect(match): protected.append(match.group(0)) return f"__MATH_BLOCK_{len(protected)-1}__" temp = re.sub(r'\$\$[^$]+?\$\$', protect, data) temp = re.sub(r'(?