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