BuddyMath / strategy_manager.py
dotandru's picture
V285.5: Multi-image ordering implementation and pedagogical grading logic
0c3327c
raw
history blame
10.6 kB
import topic_taxonomy
import micro_prompts
import validators
import asyncio
import json
import cost_tracker
import re
import logging
from utils.safe_json import safe_extract_json # V1.0: Canonical extractor (replaces local duplicate)
import prompts
logger = logging.getLogger(__name__)
class StrategyManager:
def __init__(self, llm_model):
self.llm = llm_model
self.max_retries = 2
async def solve_with_strategy(self, problem_text, data_anchor, grade="10", image_data=None, image_data_list=None, parent_category=None, ambiguity_warning=False, intent=None, intent_contract=None, proof_graph_steps_count=1, student_name="ื ืกื™ืš", student_gender="M"):
"""
V3.1.2: Added support for adaptive failure (Soft Recovery).
V5.8.2: Dynamic Token Budget added via proof_graph_steps_count.
"""
self.grade = grade # V4.2.11: Persist grade for prompt building
topic_id = topic_taxonomy.detect_topic(problem_text, grade)
detected_category = topic_taxonomy.get_topic_category(topic_id)
category = detected_category if detected_category != "GENERAL" else (parent_category or "GENERAL")
image_pages = []
if image_data_list:
# Multi-image support
for i_data in image_data_list:
image_pages.append({"mime_type": "image/jpeg", "data": i_data})
elif image_data:
try:
from ocr_strip_engine import paginate_image
image_pages = paginate_image(image_data)
except Exception as e:
logger.exception("CRITICAL FLOW ERROR")
logger.error(f"Pagination failed: {e}")
# V4.3: Single Pass (No Retries)
try:
strategy_config = self._get_strategy(topic_id)
prompt = self._build_prompt(topic_id, data_anchor, strategy_config, problem_text, category, grade, student_name, student_gender)
# V4.2 Intent Lockdown (Iron Law #2)
if intent_contract and intent_contract.get("status") != "unconstrained":
lockdown_note = f"""
๐Ÿ“ข [V4.2] PEDAGOGICAL LOCKDOWN:
- Intent: {intent}
- Max Variables: {intent_contract.get('max_variables', 'N/A')}
- Forbidden: {", ".join(intent_contract.get('forbidden_strategies', []))}
- REQUIRED: Use ONLY basic algebraic steps. DO NOT use advanced functions or calculus.
"""
prompt = lockdown_note + "\n" + prompt
# V281.1: HARD OCR DISCONNECT
# If we have image data, DO NOT inject the OCR-based function equation.
# The LLM must rely on the Vision-First extracted Data Anchor and the image itself.
if data_anchor.get("function_equations") and not image_data and not image_data_list:
prompt = f"TARGET FUNCTION: {data_anchor['function_equations'][0]}\n\n" + prompt
# V3.1.2: Soft Recovery Prompt Injection
if ambiguity_warning:
soft_recovery_note = """
System Prompt Override: ื”ื•ืกืฃ ืœืชื—ื™ืœืช ื”ื”ืกื‘ืจ ืฉืœืš ืืช ื”ื”ืขืจื” ื”ื‘ืื” ื‘ื ื™ืžื” ื™ื“ื™ื“ื•ืชื™ืช: 'ื”ื™ื™! ื”ืฆื™ืœื•ื ื”ื™ื” ืงืฆืช ืœื ื‘ืจื•ืจ, ืื‘ืœ ื ืจืื” ืœื™ ืฉื”ืชื›ื•ื•ื ืช ืœื‘ื™ื˜ื•ื™ ื”ื–ื”. ืื ื”ืชื›ื•ื•ื ืช ืœืžืฉื”ื• ืื—ืจ, ืคืฉื•ื˜ ืฆืœื ืฉื•ื‘ ืžืงืจื•ื‘!' - ื”ืžืฉืš ืœื”ืกื‘ื™ืจ ืืช ื”ืฉืœื‘ื™ื ื›ืจื’ื™ืœ.
"""
prompt = soft_recovery_note + "\n" + prompt
prompt = (prompt or "") + f"\n\n๐ŸŽฏ MISSION: Solve ONLY part: {problem_text}"
llm_response = await self._call_llm(prompt, image_data, category, image_pages, proof_graph_steps_count)
# V5.8.2: Guard against raw list responses
if isinstance(llm_response, list):
llm_response = {"steps": llm_response, "is_valid": True, "confidence_score": 1.0}
return {
"topic": topic_id,
"category": category,
"llm_response": llm_response,
"validated": llm_response.get("is_valid", True),
"confidence": llm_response.get("confidence_score", 1.0)
}
except asyncio.CancelledError:
logger.warning("V4.3 Single Pass Cancelled (Client Disconnected)")
return {
"topic": topic_id,
"category": category,
"llm_response": {"solution_markdown": "ื‘ื•ื˜ืœ ืขืœ ื™ื“ื™ ื”ืžืฉืชืžืฉ", "is_valid": False, "confidence_score": 0.0},
"validated": False
}
except Exception as e:
logger.error(f"V4.3 Single Pass Failed: {e}")
return {
"topic": topic_id,
"category": category,
"llm_response": {"solution_markdown": "ื”ืžื•ืจื” ื ืชืงืœื” ื‘ืงื•ืฉื™ ื˜ื›ื ื™.", "is_valid": False, "confidence_score": 0.0},
"validated": False
}
def _get_strategy(self, topic_id):
return {"has_micro_prompt": topic_id in micro_prompts.MICRO_PROMPTS, "has_validator": topic_id in validators.VALIDATORS}
def _build_prompt(self, topic_id, data_anchor, strategy_config, problem_text, category, grade, student_name, student_gender):
# V7.3 Hybrid Mode: Always use Specialist Prompt for the "Solution Skin"
specialist = prompts.get_specialist_prompt(
category=category,
problem_text=problem_text,
solver_hint="Hybrid Navigation Mode",
grade=grade,
student_name=student_name,
student_gender=student_gender,
data_anchor=data_anchor
)
if strategy_config["has_micro_prompt"]:
try:
focus = micro_prompts.get_micro_prompt(topic_id, data_anchor, grade=self.grade)
return f"{focus}\n\n{specialist}"
except:
pass
return specialist
return micro_prompts.get_general_prompt(data_anchor)
async def _call_llm(self, prompt, image_data, category, image_pages, proof_graph_steps_count=1):
# V8.5: No More Patchwork. Use centralized V4.3.0 standard.
v430_instruction = prompts.get_master_prompt_v430(category=category, problem_text=prompt)
prompt = (prompt or "") + (v430_instruction or "")
from google.generativeai.types import GenerationConfig
import asyncio
# V5.8.3: Pre-release Hardening (Increased Budget for Preamble)
# V8.6: Golden Merge - Increased to 8192 to support high-density Micro-Stepping
max_tokens = 8192
logger.info(f"๐Ÿช™ [BUDGET] Dynamic Token Budget allocated: {max_tokens} tokens for {proof_graph_steps_count} steps.")
gen_config = GenerationConfig(
temperature=0.0,
top_p=0.1,
top_k=1,
max_output_tokens=max_tokens,
response_mime_type="application/json"
)
try:
if image_pages:
payload = [f"Images are sequential. Image 1 is Header.\n{prompt}"] + image_pages
logger.info(f"๐Ÿง  [TRACE] PROMPT SENT TO LLM: {payload[0]}")
# V8.5: True Async Cancellation. Using current_task to handle disconnects.
response = await asyncio.wait_for(
self.llm.generate_content_async(payload, generation_config=gen_config),
timeout=60.0
)
else:
logger.info(f"๐Ÿง  [TRACE] PROMPT SENT TO LLM: {prompt}")
response = await asyncio.wait_for(
self.llm.generate_content_async(prompt, generation_config=gen_config),
timeout=30.0
)
except asyncio.CancelledError:
# HARD STOP: Client disconnected. Active cancellation is handled by wait_for + generate_content_async
logger.warning("๐Ÿ“‰ [V8.5] LLM Call actively CANCELLED due to client disconnect.")
# Repropagate to allow parent orchestrator to catch it
raise
except Exception as e:
logger.error(f"Error in _call_llm generation: {e}")
raise
raw_text = response.text
logger.info(f"๐Ÿ“ฆ [TRACE] RAW RESPONSE RECEIVED: {raw_text}")
# V8.5.1: Convert UsageMetadata (Custom Class) to dict to avoid serialization error
usage = getattr(response, 'usage_metadata', None)
usage_dict = {}
if usage:
usage_dict = {
"prompt_token_count": getattr(usage, 'prompt_token_count', 0),
"candidates_token_count": getattr(usage, 'candidates_token_count', 0),
"total_token_count": getattr(usage, 'total_token_count', 0)
}
parsed = self._parse_v4_json(raw_text)
return {**parsed, "usage_metadata": usage_dict}
def _parse_v4_json(self, raw_text):
# V4.7 โ†’ V1.0: Use canonical safe_extract_json (logs RAW, LaTeX shield, json_repair, fail-closed)
if "{" not in raw_text and "[" not in raw_text:
return {"solution_markdown": "ืฉื’ื™ืืช ืžื‘ื ื”", "is_valid": False, "confidence_score": 0.1}
result = safe_extract_json(raw_text, caller="STRATEGY_MANAGER")
if isinstance(result, dict) and result.get("logic_error"):
return {"solution_markdown": "ื”ืžื•ืจื” ื ื›ืฉืœื” ื‘ืคื™ืจื•ืฉ ื”ืคืชืจื•ืŸ", "is_valid": False, "confidence_score": 0.1}
return result
def _validate(self, topic_id, llm_response, data_anchor): return {"valid": True, "error": None}
async def generate_raw(self, system_prompt: str, user_prompt: str) -> str:
"""
V6.1: Direct raw LLM call for ProposalEngine. Unconstrained output.
"""
from google.generativeai.types import GenerationConfig
import asyncio
full_prompt = f"{system_prompt}\n\nUser Request: {user_prompt}"
gen_config = GenerationConfig(
temperature=0.2, # Slight creativity needed to draft proofs, but still grounded
top_p=0.8,
)
logger.info(f"๐Ÿง  [PROPOSAL-GATEWAY] Sending PROMPT to LLM.")
try:
response = await asyncio.wait_for(
asyncio.to_thread(self.llm.generate_content, full_prompt, generation_config=gen_config),
timeout=45.0
)
return getattr(response, 'text', '')
except Exception as e:
logger.error(f"โŒ [PROPOSAL-GATEWAY] LLM Request failed: {e}")
raise e