| |
| 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 |
| from utils.safe_json import safe_extract_json |
| from domain.math_validator import MathPolygraph |
| from geometric_sanity import run_geometric_sanity |
| 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 |
| 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 |
|
|
| |
| 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") |
|
|
| |
| GLOBAL_TOKEN_LIMIT = 100000 |
| GLOBAL_TIMEOUT_SEC = 300 |
|
|
| |
|
|
|
|
| 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", [])) |
| |
| 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 = [] |
| 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}") |
|
|
| |
| 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 |
| } |
|
|
|
|
| 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 {} |
|
|
| |
| abstract = {} |
|
|
| graph_relations = [] |
| if "graphs" in data_anchor: |
| for i, g in enumerate(data_anchor["graphs"]): |
| graph_relations.append({ |
| "graph": chr(ord("I") + i), |
| "zeros": g.get("zeros", 0), |
| "type": g.get("type", "unknown") |
| }) |
|
|
| if graph_relations: |
| abstract["graph_relations"] = graph_relations |
|
|
| return abstract |
|
|
|
|
| |
|
|
| 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. |
| """ |
| |
| clean_text = re.sub(r'\{\{\w+\}\}', '', rendered_text).strip() |
|
|
| if not clean_text: |
| return True |
|
|
| ALLOWED_PATTERN = r'^[\u0590-\u05FFa-zA-Z\s.,;:!?\-\"\']+$' |
|
|
| if re.match(ALLOWED_PATTERN, clean_text): |
| return True |
|
|
| |
| 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", "") |
| |
| 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 |
| |
| 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 |
| |
| |
| 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 {} |
|
|
| 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: |
| |
| if "SYMPY_PARSE_ERROR" in block_math: |
| step["block_math"] = "" |
| step["content_mixed"] = step.get("content_mixed", "") + "\n(ืืืฉืืืื ืืืกืชืจื ืขืงื ืงืืฉื ืืชืฆืืื)." |
| |
| |
| 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"] = "" |
| |
| |
| 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) |
|
|
| |
| if isinstance(raw_output, (dict, list)): |
| return raw_output |
|
|
| if hasattr(raw_output, 'text'): |
| raw_output = raw_output.text |
|
|
| |
| 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)}") |
| |
| 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): |
| |
| if len(proof_steps) != len(llm_output): |
| return False, "PEDAGOGICAL_STEP_MISMATCH" |
|
|
| |
| 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. |
| """ |
| |
| if logic_error: |
| |
| |
| 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}") |
| return response |
|
|
| def build_structured_projection(llm_commentaries, sympy_steps): |
| """ืืืื ืืกืืจืื ืืืืืืืื ืขื ืืืื ืืืชืืื ืืื ืืืข ืื ืืื (V4.2.7)""" |
| structured_response = [] |
| |
| for i, step in enumerate(sympy_steps): |
| commentary = llm_commentaries[i] if i < len(llm_commentaries) else "ื ืืฆืข ืืช ืืฉืื ืืื." |
| |
| |
| 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, |
| "explanation_text": commentary, |
| "content_mixed": commentary, |
| "math_artifact": { |
| "type": artifact_type, |
| "latex": step.math_content, |
| "table_data": "" |
| }, |
| "block_math": step.math_content |
| }) |
| 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: |
| |
| 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}") |
| |
| return False, 0.5 |
| from dotenv import load_dotenv |
| load_dotenv() |
| import google.generativeai as genai |
| from smart_solver import SmartSolver |
| from gibberish_detector import validate_and_fix_solution |
| import gibberish_detector |
| import visuals |
|
|
| |
| from strategy_manager import StrategyManager |
| from pedagogical_builder import build_pedagogical_response, sanitize_math_text |
| import cost_tracker |
| from audio_generator import generate_teacher_audio |
|
|
| |
| 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 |
|
|
| |
| |
| |
|
|
| |
| 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 = "" |
| sub_question_text: str = "" |
|
|
| 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", "")) |
| |
| 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() |
| |
| |
| self.strategy_manager = StrategyManager(self.model) |
| self._last_ocr_confidence = 1.0 |
| print("๐ฏ [BIT-LOG] StrategyManager initialized") |
|
|
| |
|
|
| def _quick_classify(self, problem_text: str) -> ProcessingStrategy: |
| """ |
| V5.8.0: Deterministic classification returning strict ProcessingStrategy |
| """ |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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}") |
| |
| |
| 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]}...") |
| |
| |
| 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": |
| |
| print(f"๐ท๏ธ [CLASSIFY] Medium confidence: {quick_result['complexity']} ({quick_result['source']})") |
| return quick_result |
| |
| |
| print(f"๐ท๏ธ [CLASSIFY] Needs LLM classification...") |
| return await self._llm_classify(problem_text) |
|
|
| |
|
|
| 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: |
| |
| 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}") |
| |
| |
| 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", "") |
| |
| |
| 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 |
| }] |
| |
| |
| 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 |
|
|
| |
| |
| |
|
|
| 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 "" |
|
|
| |
| if isinstance(ocr_data, str): |
| s = ocr_data.strip() |
| if (s.startswith('[') and s.endswith(']')) or (s.startswith('{') and s.endswith('}')): |
| try: |
| |
| parsed_data = json.loads(s) |
| ocr_data = parsed_data |
| except json.JSONDecodeError: |
| |
| return s |
| else: |
| return s |
|
|
| |
| if isinstance(ocr_data, list): |
| |
| |
| parts = [] |
| for item in ocr_data: |
| if isinstance(item, dict): |
| |
| 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 "" |
| |
| |
| elif isinstance(ocr_data, dict): |
| |
| 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()]) |
|
|
| |
| 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()}") |
|
|
| |
| if ocr_mode == "development": |
| debug = True |
| 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, |
| ) |
| |
| 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") |
|
|
| |
| print("๐ธ ๐ต [BIT-LOG] Starting OCR (Triple Pass - V231.8)...") |
| self._last_ocr_confidence = 0.85 |
| prompt = prompts.get_transcription_prompt() |
|
|
| results = [] |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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) |
| |
| final_text = self._flatten_ocr_payload(final_text) |
| |
| |
| 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)) |
| |
| 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}") |
| |
| 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 |
| score += text.count('ืฉืืื') * 5 |
| score += len(text) // 50 |
| 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): |
| 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): |
| |
| 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 |
| ) |
| 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()) |
| |
| |
| data = unify_data_anchor(data) |
| if isinstance(data, dict): |
| print(f"โ [BIT-LOG] Unified Data Anchor: {json.dumps(data, ensure_ascii=False)[:100]}...") |
|
|
| |
| 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 |
|
|
| |
| if data and isinstance(data, dict): |
| |
| if 'function_equations' in data: |
| valid_eqs = [] |
| for eq in data['function_equations']: |
| |
| 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 |
| |
| |
| if not valid_eqs: |
| |
| 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) |
| |
| |
| if not problem_understanding.validate_understanding(understanding): |
| print("โ ๏ธ [BIT-LOG] Invalid understanding structure, using fallback") |
| return self._create_fallback_understanding(problem_text, data_anchor) |
| |
| |
| 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) |
| |
| |
| if image_data: |
| |
| res = await asyncio.wait_for( |
| self.vision_model.generate_content_async([ |
| prompt, |
| {"mime_type": "image/png", "data": image_data} |
| ]), |
| timeout=15.0 |
| ) |
| else: |
| |
| 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...") |
| |
| |
| 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')})") |
| |
| 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}") |
| |
| |
| return {"approved": True, "feedback": ""} |
|
|
| |
|
|
| 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. |
| """ |
| |
| 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: |
| print("๐๏ธ [V285.1] Generating LLM pedagogical summary...") |
| |
| summary_prompt = prompts.get_teacher_summary_prompt( |
| student_name=student_name, |
| student_gender=student_gender |
| ) |
| |
| |
| 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") |
| |
| |
| 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", ""), |
| } |
| |
| |
| 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 |
|
|
| |
|
|
| 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}") |
|
|
| |
| yield BuddyEvent( |
| question_id=question_id, |
| state=BuddyState.SECTION_WORKING, |
| current_section_id="CHECK", |
| payload={"status": "ืืืืจื ืืืืงืช ืืช ืืขืืืื ืฉืื... ๐"} |
| ) |
|
|
| try: |
| |
| |
| 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) |
|
|
| |
| check_prompt = prompts.get_check_me_prompt( |
| grade=grade, |
| student_name=student_name, |
| student_gender=student_gender, |
| data_anchor=data_anchor |
| ) |
|
|
| |
| 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]}") |
|
|
| |
| 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 |
|
|
| |
| 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)}") |
|
|
| |
| |
| |
| 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] |
| } |
|
|
| |
| 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 |
| ) |
|
|
| |
| |
| |
| 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 "" |
|
|
| |
| 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 |
| ) |
|
|
| |
| |
| |
| 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": "" |
| }] |
| } |
| ) |
|
|
| |
| |
| |
| from pedagogical_builder import sanitize_math_text |
| |
| |
| 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, |
| "correct_answer": safe_correct_answer, |
| "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, |
| ) |
| ) |
|
|
| |
|
|
|
|
| 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: |
| |
| yield BuddyEvent( |
| question_id=question_id, |
| state=BuddyState.SECTION_WORKING, |
| current_section_id="ืื ืืืช ืืกืืจืืืื", |
| payload={"status": "ืืืืจื ืืื ื ืืกืืจืืืื ืืคืชืจืื..."} |
| ) |
|
|
| |
| understanding = await self._understand_problem(problem_text, data_anchor) |
| |
| |
| |
| |
| 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) |
| |
| |
| strategy_card = await self._generate_strategy_card(problem_text, data_anchor) |
| |
| |
| yield BuddyEvent( |
| question_id=question_id, |
| state=BuddyState.STRATEGY_READY, |
| payload={"sections": [strategy_card]} if strategy_card else {} |
| ) |
|
|
| |
| |
| visual_categories = ["GEOMETRY", "GEOMETRY_ANALYTIC", "TRIGONOMETRY", "INVESTIGATION", "FUNCTIONS", "GEOMETRIC_LOCUS", "CALCULUS"] |
| problem_type = understanding.get("problem_type", "").upper() |
| |
| |
| explicit_drawing_keywords = ["ืฉืจืื", "ืกืจืื", "ืกืงืืฆื", "ืืจืฃ", "ืืืืฉื", "ืฆืืืจ", "ืืืืื", "ืฉืจืืื", "ืืืื ืื ืืืจืคืื", "ืืืื ืืืืจืคืื", "ืืืื ืืจืฃ ืืชืืจ"] |
| is_explicitly_requested = any(keyword in problem_text for keyword in explicit_drawing_keywords) |
| |
| |
| 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 |
| |
| |
| 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: |
| |
| |
| 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 |
| |
| |
| all_solutions = [] |
| context = {} |
| total_tokens = 0 |
| |
| for sub_q in understanding['sub_questions']: |
| print(f"๐ [SOLVING] Sub-question {sub_q['id']}: {sub_q['topic']}") |
| |
| |
| yield BuddyEvent( |
| question_id=question_id, |
| state=BuddyState.SECTION_WORKING, |
| current_section_id=sub_q['id'], |
| payload={"question": sub_q['question']} |
| ) |
| |
| |
| |
| |
| effective_category = understanding.get('problem_type', category) |
| |
| |
| |
| grade_num = math_intent_detector._extract_grade_number(grade) |
| curriculum_rules = curriculum_engine.get_allowed_math_operators(grade, level=kwargs.get('level', '4')) |
| |
| |
| 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) |
| |
| |
| if strategy_vector.get("forbidden"): |
| intent_contract["forbidden_strategies"] = list(set(intent_contract.get("forbidden_strategies", []) + strategy_vector["forbidden"])) |
| |
| |
| ocr_confidence = self._last_ocr_confidence |
| |
| |
| eqs = data_anchor.get("function_equations", []) |
| joined_eqs = " , ".join(eqs) if eqs else sub_q['question'] |
| |
| |
| 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', '') |
| ) |
|
|
| |
| complexity_score = CurriculumClassifier.estimate_complexity(cleaned_math) |
| prompt_specialization = CurriculumClassifier.get_prompt_specialization(grade, effective_category) |
|
|
| |
|
|
| |
| ast_metadata = build_ast_metadata(cleaned_math, effective_category) |
| ast_registry = ast_metadata.pop("ast_registry") |
| visual_context = _abstract_visual_context(data_anchor or {}) |
| if visual_context: |
| ast_metadata["visual_context"] = visual_context |
|
|
| |
| 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 |
|
|
| |
|
|
| |
| 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" |
|
|
| |
| |
| |
| print("๐ง [V7.3] Triggering LLM Navigation Mode...") |
| |
| |
| |
| |
| |
| attempts = 0 |
| max_attempts = 2 |
| solved_data = None |
| last_error_context = "" |
| is_degraded = False |
| degraded_reason = None |
| |
| while attempts < max_attempts: |
| attempts += 1 |
| |
| |
| 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})") |
| |
| |
| solve_prompt = sub_q['question'] |
| if last_error_context: |
| solve_prompt = f"FIX ERROR: {last_error_context}\n\nORIGINAL QUESTION: {solve_prompt}" |
|
|
| |
| |
| |
| |
| 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"]) |
| |
| |
| restricted_pool = set(all_sub_q_values) |
| |
| local_anchor = {**data_anchor} |
| global_values = local_anchor.get("specific_values", []) |
| |
| |
| truly_global_values = [v for v in global_values if v not in restricted_pool] |
| |
| |
| 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"): |
| |
| last_error_context = "LLM_MALFORMED_JSON_OR_TIMEOUT" |
| continue |
|
|
| llm_resp = result.get("llm_response", {}) |
| |
| |
| usage = llm_resp.get("usage_metadata") |
| if usage: |
| |
| 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", []) |
|
|
| |
| 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: |
| |
| 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 |
| |
| |
| solved_data = llm_resp |
| |
| |
| if "SYMPY_PARSE_ERROR" in str(poly_reason): |
| |
| |
| |
| 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" |
| |
| else: |
| |
| 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 |
| 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" |
|
|
| |
| if not solved_data: |
| is_degraded = True |
| degraded_reason = "polygraph_fail" |
| print(f"๐ก๏ธ [V8.5] ESCAPE HATCH TRIGGERED for sub-q {sub_q['id']}") |
| |
| |
| |
| if llm_steps: |
| solved_data = { |
| "steps": llm_steps, |
| "final_answer": llm_resp.get("final_answer") if isinstance(llm_resp, dict) else "ืืืืง ืืช ืืฆืขืืื" |
| } |
| |
| 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": "ืืืฉื ืืคื ืืฉืืืื" |
| } |
|
|
| |
| |
| context[f"result_{sub_q['id']}"] = solved_data.get("final_answer", "No valid answer extracted") if isinstance(solved_data, dict) else "ืืืฉืื" |
| |
| |
| |
| 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 |
| |
| |
| 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 |
| ) |
| |
| |
| if is_degraded: |
| response["is_degraded"] = True |
| response["degraded_reason"] = degraded_reason |
| |
| |
| if graph_svg: |
| response["graph_svg"] = graph_svg |
| response["graph_base64"] = "" |
|
|
| |
| 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 |
| }) |
| |
| |
| final_response = self._build_multi_part_response( |
| all_solutions, |
| strategy_card=strategy_card, |
| visual_context=visual_context |
| ) |
| |
| |
| if visual_context: |
| print("๐ [BIT-LOG] Generating graph from visual context...") |
| try: |
| |
| latex_for_plot = visual_context.get("latex_input", "") |
| if latex_for_plot: |
| |
| 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"] = "" |
| 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}") |
|
|
| |
| print("๐๏ธ [BIT-LOG] Starting V285.1 Smart Teacher Summary...") |
| |
| |
| summary_result = await self._generate_teacher_summary( |
| problem_text, |
| all_solutions, |
| understanding, |
| proof_graph=None, |
| student_name=student_name, |
| student_gender=student_gender |
| ) |
| |
| |
| 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) |
| |
| |
| 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}") |
| |
| |
| |
| topic_summary = summary_result.get("topic_summary", "") |
| key_concepts = summary_result.get("key_concepts", []) |
| formulas = summary_result.get("formulas_to_remember", []) |
| |
| |
| 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") |
| |
|
|
| |
| tier = kwargs.get('tier', 'student_basic') |
| |
| 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: |
| |
| 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}") |
|
|
| |
| 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() |
| |
| |
| 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") |
| |
| |
| 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" |
| |
| if isinstance(res, dict): |
| |
| if res.get("logic_error"): |
| |
| sub_q_status = "FAILED_RULE" |
| |
| if "sections" in res and res["sections"]: |
| |
| 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: |
| |
| 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 |
| |
|
|
|
|
| |
|
|
| 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') |
|
|
| |
| if session_id and uid: |
| print(f"๐ [TUTOR-MODE] Activating Session-Based Dialogue for session: {session_id}") |
| |
| event = await self._handle_tutor_session(problem_text, student_name, uid, session_id, **kwargs) |
| yield event |
| return |
| |
| 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] |
| |
| |
| 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() |
| |
| |
| |
| yield BuddyEvent( |
| question_id=question_id, |
| state=BuddyState.SECTION_WORKING, |
| current_section_id="ื ืืชืื ืชืืื ื", |
| payload={"status": "ืืืืจื ืงืืจืืช ืืช ืืฉืืื..."} |
| ) |
|
|
| |
| 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 |
| |
| |
| 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] |
| |
| 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)") |
| |
| |
| import re |
| ocr_clean = problem_text.strip() if problem_text else "" |
| |
| |
| has_math_anchor = bool(re.search(r'[0-9xyzXYZ=+\-\(\)]', ocr_clean)) |
| |
| |
| is_short_math = has_math_anchor and len(ocr_clean) < 15 and len(ocr_clean) > 2 |
| |
| |
| 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, |
| |
| |
| ) |
| ) |
| return |
| |
| |
| 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}") |
| |
| |
| strategy = self._quick_classify(problem_text) |
| print(f"๐ท๏ธ [BIT-LOG] Strategy: {strategy.value}") |
|
|
| |
| from cost_tracker import CostTracker |
| tokens = CostTracker() |
|
|
| |
| 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) |
| |
| _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 |
|
|
| |
| print(f"๐ฏ [BIT-LOG] Using Streaming Pipeline Strategy: {strategy.value}") |
|
|
| |
| |
| 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 {} |
| |
| |
| 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) |
|
|
| |
| |
| |
| 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}") |
| |
| |
| |
| 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", |
| 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 |
| ): |
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| pass |
|
|
| except Exception as e: |
| logger.exception("STREAMING ERROR") |
| yield BuddyEvent( |
| question_id=question_id, |
| state=BuddyState.ERROR, |
| payload={"error": str(e), "message": "ืืืคืก! ืืฉืื ืืฉืชืืฉ ืืขืืืื ืืฉืืื."} |
| ) |
| |
|
|
| |
| 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. |
| """ |
| |
| 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 |
| ) |
| |
| |
| 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") |
| |
| |
| 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', "ืืืชื ื'") |
|
|
| |
| history_docs = firebase_manager.get_chat_history(uid, session_id, limit=10) |
| |
| |
| 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', '')] |
| }) |
|
|
| |
| system_instruction = f"""ืืช ืืืจื ืคืจืืืช ืืืชืืืืงื. ืืืืจื ืฉืื ืืื ืื ืื ืืืืืื ืืืืื ืขื ืืชืืืื {student_name} (ืืืชื {grade}). |
| ืืืงื ืืฉืืื: |
| 1. ืื ืื ืชืืืืช ืฉืืื (ืืืกืืืจืื ืจืืงื): ืื ืชืคืชืจื ืืืื. ืฉืืื ืืช ืืชืืืื: 'ืืื {student_name}! ืื ืื ืื ื ืืืืืื ืืืื ืืืืชื?' ืืืื ืืชืฉืืื. |
| 2. ืืจืืข ืฉืืืื ื ืืฉื: ืฉืืจื ืืืชื ืืืืืจืื ืืฉืืื ืืืชืืืืก ืืืื ืืืืฉื. |
| 3. ืืืงืฉืช 'ืืืงื ืื' (ืืฉืืฉ ืชืืื ืืช ืฉื ืคืชืจืื): ื ืชืื ืืช ืืชืืื ื ืืื ืืฉืืื. ืื ืืฉ ืืขืืช, ืฆืืื ื ืืช ืืฉืืจื ืืืช ืกืื ืืืขืืช (ืกืืื ืื, ืืืงื ืืืงืืช ืืื'). ืื ืชืชืงื ื ืืื, ืชื ื ืจืื. |
| 4. ืืฉืชืืฉ ืืืื ืืขืืื, ืื ืืืืืื ืืขืื ืืื. |
| 5. ืื ืคืื ืืืื ืืืืืช ืืคืืจืื JSON ืชืงืื ืืคื ืืกืืืื ืืืืืืจืช. |
| """ |
|
|
| |
| 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("(ืืชืืืื ืืฆืืจืฃ ืืฉืืื)") |
|
|
| |
| try: |
| |
| chat = self.model.start_chat(history=history_contents) |
| |
| |
| generation_config = genai.GenerationConfig( |
| response_mime_type="application/json", |
| response_schema=TutorResponseSchema, |
| temperature=0.7 |
| ) |
|
|
| |
| |
| |
| 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 |
| ) |
|
|
| |
| data = json.loads(res.text) |
| student_message = data.get("student_message", "") |
| analytics = data.get("internal_analytics", {}) |
|
|
| |
| intent = analytics.get("intent", "CHAT") |
| mastery_score = analytics.get("mastery_score", 0) |
| topic = analytics.get("topic", "General") |
| |
| |
| 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 |
| |
| asyncio.create_task(exercise_generator.generate_challenge(problem_text or "(ืืขืื ืชืืื ื)", topic, uid)) |
| |
| |
| offer_text = "\n\nืื ืืืืื! ืืื ืชื ืื ืชืจืืื ืืชืืจ ืืืื ืืื ืืืืื ืฉืื ืืืฉื ืืื. ืจืืฆื ืื ืกืืช? ๐ช" |
| student_message += offer_text |
|
|
| |
| if problem_text or image_data_list: |
| firebase_manager.save_chat_message( |
| uid, session_id, "user", problem_text or "(ืชืืื ื)", |
| metadata={"intent": intent} |
| ) |
|
|
| |
| firebase_manager.save_chat_message( |
| uid, session_id, "assistant", student_message, |
| metadata=analytics |
| ) |
|
|
| |
| 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": "ืืืืจื ื ืชืงืื ืืืขืื ืืืืืจืื ืฉื ืืฉืืื."} |
| ) |
|
|
| |
|
|
| 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'(?<!\\)(frac|sqrt|cdot|times|neq|pm|alpha|beta|gamma|Delta|pi|infty|leq|geq|quad)', |
| r'\\\1', content |
| ) |
| content = re.sub(r'(?<!\\)(sin|cos|tan|log|ln)\b', r'\\\1', content) |
| return content |
|
|
| |
| res = re.sub(r'(?m)^\s*\$(.+?)\$\s*$', r'$$\1$$', data) |
|
|
| res = re.sub(r'\$\$(.+?)\$\$', lambda m: f'$${_fix_content(m.group(1))}$$', res, flags=re.DOTALL) |
| res = re.sub(r'(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)', lambda m: f'${_fix_content(m.group(1))}$', res) |
| return res |
| if isinstance(data, dict): |
| return {k: self._enhance_latex_v2(v) for k, v in data.items()} |
| if isinstance(data, list): |
| return [self._enhance_latex_v2(i) for i in data] |
| return data |
|
|
| def _scrub_strategy_hebrew(self, data): |
| """โ
V231.5: Scrubs $ \\ { from Hebrew side of :: separator in strategy cards.""" |
| if isinstance(data, dict): |
| sections = data.get('sections', []) |
| if sections and isinstance(sections, list) and len(sections) > 0: |
| first_sec = sections[0] |
| title = first_sec.get('section_title', '') |
| if 'ื ืืืฉืื' in title or 'ืืกืืจืืืื' in title or '๐งญ' in title: |
| |
| 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: |
| |
| 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).""" |
| |
| |
| |
| if isinstance(data, str): |
| |
| 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): |
| |
| data = re.sub(r'\${3,}', '$$', data) |
| |
| |
| data = re.sub(r'\$\$\s*\$\$', '', data) |
| |
| |
| 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 '' |
| |
| if hebrew_chars / total_chars > 0.25 and hebrew_chars > 8: |
| return content |
| |
| 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) |
| |
| |
| |
| |
| |
| protected = [] |
| |
| def protect_display_math(match): |
| protected.append(match.group(0)) |
| return f"__DISPLAY_MATH_{len(protected)-1}__" |
| |
| |
| result = re.sub(r'\$\$[^$]+?\$\$', protect_display_math, data) |
| |
| |
| result = re.sub(r'\$\$+', '$', result) |
| |
| |
| 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) |
| |
| |
| s = re.sub(r'\\color\{.*?\}(?:\{.*?\})?', '', s) |
| 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) |
| |
| |
| 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', '') |
| |
| |
| 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', '') |
| |
| |
| s = s.replace('{', '(').replace('}', ')') |
| |
| |
| s = s.replace('^', '**') |
| |
| |
| for _ in range(3): |
| s = re.sub(r'(\d)([a-zA-Z(])', r'\1*\2', s) |
| s = re.sub(r'\)([a-zA-Z0-9(])', r')*\1', s) |
| s = re.sub(r'([a-zA-Z])(?<![a-zA-Z])\(', r'\1*(', s) |
| |
| |
| for func in ['sin', 'cos', 'tan', 'sqrt', 'log', 'ln', 'exp', 'Abs', 'abs', 'pi']: |
| s = s.replace(f'{func}*(', f'{func}(') |
| |
| |
| s = re.sub(r'[^\S\r\n]+', ' ', s) |
| s = re.sub(r'[\u0590-\u05FF\u200B-\u200D\uFEFF\'"`;@#%&!?]', '', s) |
| s = re.sub(r'\\[a-zA-Z]+', '', s) |
| |
| |
| s = s.lstrip('=').rstrip('.,;') |
| |
| print(f"๐ ๐ต [BIT-LOG] SymPy Sanitized: '{expr}' โ '{s}'") |
| return s |
|
|
| def _deep_sanitize_math(self, text: str) -> str: |
| """V281.1: Aggressively strips non-printable characters from math blocks.""" |
| if not text: return "" |
| |
| 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) |
| |
| |
| 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 |
| |
| |
| 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: |
| |
| 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) |
| |
| |
| |
| text = re.sub(r'\$.*?\$', '', text) |
| |
| text = re.sub(r'\\[a-zA-Z]+', '', text) |
| |
| text = re.sub(r'[\{\}\[\]]', '', text) |
| |
| text = re.sub(r'[\^_*=+\-/|]', '', text) |
| |
| |
| 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 "ืชืจืืื ืืืชืืืืงื" |
| |
| |
| 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 |
| |
| 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() |
|
|
| |
|
|
| 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 "" |
| |
| |
| chatter_words = ( |
| r"(oops|sorry|mistake|apologize|let me correct|my bad" |
| r"|ืืืคืก|ืกืืืื|ืืขืืช|ืืชื ืฆื|ืชืืงืื|ืฉืืืชื|ืืขืืชื|ืืฆืืขืจ" |
| r"|ืืื ื ืชืงื|ืจืืข|ืฉืื ืื ืืืขืืช|ืืืืชื ืืขืืช|ื ืคืื ืืขืืช|ืชืืงืื ืืขืืช|ืืืืฉืื ืืงืืื" |
| |
| r"|ืืขืืช ืืฉืืื|ืืื ื ื ืกื ืฉืื|ืกืืฃ ืกืืฃ ืืื ืชื|ืขื ืืืืืื" |
| r"|ืื ืื ืขืืื|ืื ืขืืฉืื|ืื ืื ืืื|ืื ืื ื ืืื|ืื ืงืืจื ืคื" |
| r"|ืืื ื ืืฉืื ืขื ืื|ืฉืื ืืืืชื|ืืื ื ืืืืจ ืืืืจื|ืืื ื ืชืืื ืืืชืืื" |
| r"|ืื ื ืงืฆืช ืืืืืื|ืขื ืื ืืืขืืืืช|ื ืืกืคืช|ืงืื ืกืคืืืืืืช|ืืฉืืื" |
| r"|wait|ืจืืข|let me think|hold on)" |
| ) |
| |
| |
| |
| chatter_sentence_pattern = r"(?i)\s*[^.!?\n]*?(?:" + chatter_words + r")[^.!?\n]*?[.!?]?" |
| |
| cleaned = text |
| cleaned = re.sub(chatter_sentence_pattern, "", cleaned) |
| |
| |
| 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): |
| |
| 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'(?<!\$)\$(?!\$)[^$]+?(?<!\$)\$(?!\$)', protect, temp) |
| |
| |
| |
| temp = temp.replace(r'\cdot', '') |
| temp = temp.replace(r'\quad', ' ') |
| temp = temp.replace(r'\text', '') |
| |
| temp = re.sub(r'\\[a-zA-Z]+', '', temp) |
| |
| temp = temp.replace('{', '').replace('}', '') |
| |
| |
| for i, block in enumerate(protected): |
| temp = temp.replace(f"__MATH_BLOCK_{i}__", block) |
| |
| return temp |
| |
| elif isinstance(data, dict): |
| return {k: self._scrub_latex_from_text(v) for k, v in data.items()} |
| elif isinstance(data, list): |
| return [self._scrub_latex_from_text(i) for i in data] |
| return data |
|
|
| def _sanitize_and_split(self, data): |
| """V230.4: Mixed-to-Inline Fix. Modified to allow equations to stay as block math.""" |
| if isinstance(data, str): |
| text_outside_math = re.sub(r'\$\$.*?\$\$', '', data, flags=re.DOTALL) |
| has_hebrew_outside = bool(re.search(r'[\u0590-\u05FF]', text_outside_math)) |
| |
| def splitter(match): |
| content = match.group(1) |
| |
| if re.search(r'[\u0590-\u05FF]', content): |
| return f" ${content}$ " |
| |
| |
| |
| |
| return f"$${content}$$" |
| |
| return re.sub(r'\$\$(.*?)\$\$', splitter, data, flags=re.DOTALL) |
| elif isinstance(data, dict): |
| return {k: self._sanitize_and_split(v) for k, v in data.items()} |
| elif isinstance(data, list): |
| return [self._sanitize_and_split(i) for i in data] |
| return data |
|
|
| orchestrator = BuddyOrchestrator() |