File size: 8,977 Bytes
9d29c62 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | # domain/proposal_engine.py - V7.2 (Deterministic Governed Reasoning Runtime)
import json
import logging
from typing import List
from enum import Enum
from pydantic import BaseModel, ValidationError
from .ontology import get_allowed_concepts
import domain.telemetry as telemetry
logger = logging.getLogger(__name__)
# ==================== V7.2: STRICT SCHEMA DEFINITIONS ====================
class ComputeAction(str, Enum):
"""V7.3: Hardcoded allowed actions. The Planner may NOT invent new ones."""
# ── Algebraic (V7.2) ──────────────────────────────────────────────────
SOLVE_EQUATION = "SOLVE_EQUATION"
SIMPLIFY = "SIMPLIFY"
FIND_DERIVATIVE = "FIND_DERIVATIVE"
FIND_INTEGRAL = "FIND_INTEGRAL"
FACTOR = "FACTOR"
EXPAND = "EXPAND"
SUBSTITUTE = "SUBSTITUTE"
# ── Geometry / Analytic (V7.3) ──────────────────────────────────────
FIND_AXIS_INTERSECTIONS = "FIND_AXIS_INTERSECTIONS" # הצב x=0 / y=0 ופתור
CALCULATE_SLOPE_AND_LINE = "CALCULATE_SLOPE_AND_LINE" # שיפוע + משואת ישר
CALCULATE_DISTANCE = "CALCULATE_DISTANCE" # נוסחת מרחק + בדיקת מיקום
class ServerComputeTask(BaseModel):
action: ComputeAction # Enum enforced: unknown action → ValidationError
target_step_ref: str # AST Node ID only (e.g. "ast_node_2"), NOT a math expression
class PlannerResponse(BaseModel):
pedagogical_rationale: str
requires_server_compute: List[ServerComputeTask]
class PlannerFormatError(Exception):
"""Raised when the Planner's response fails structural or schema validation."""
pass
MAX_SERVER_STEPS = 8 # DOS Prevention: Planner cannot request more than 8 server tasks
# ==================== V7.2: SAFE JSON EXTRACTION ====================
def extract_and_validate_plan(raw_response: str) -> PlannerResponse:
"""
V7.2: Index-based JSON extraction with PlannerFormatError on any failure.
Refuses split() to avoid silent IndexError.
"""
if "<JSON_START>" not in raw_response or "<JSON_END>" not in raw_response:
raise PlannerFormatError("Missing explicit JSON boundaries <JSON_START>/<JSON_END>.")
start = raw_response.index("<JSON_START>") + len("<JSON_START>")
end = raw_response.index("<JSON_END>")
raw_json = raw_response[start:end].strip()
try:
plan = PlannerResponse.model_validate_json(raw_json)
# Guard: DOS Prevention
if len(plan.requires_server_compute) > MAX_SERVER_STEPS:
raise PlannerFormatError(
f"DOS Guard: Plan requested {len(plan.requires_server_compute)} steps, max is {MAX_SERVER_STEPS}."
)
return plan
except (ValidationError, json.JSONDecodeError) as e:
raise PlannerFormatError(f"Validation failed: {e}")
except PlannerFormatError:
raise
except Exception as e:
raise PlannerFormatError(f"Unexpected extraction error: {e}")
# ==================== V7.2: PROPOSAL ENGINE ====================
class ProposalEngine:
"""
V7.2 Deterministic Governed Reasoning — Pedagogical Planner (LLM #1)
The LLM receives only AST metadata (IDs and operations) and returns
a structured Plan (JSON). It NEVER writes math expressions — only references AST Node IDs.
"""
def __init__(self, llm_gateway):
self.llm_gateway = llm_gateway
def _build_system_prompt(self, ast_metadata: dict, prompt_specialization: str, sub_question_text: str = "") -> str:
"""Build the strict Planner system prompt including boundary enforcement."""
allowed_concepts = get_allowed_concepts(
ast_metadata.get("detected_operations", ["general"])[0],
"general_algebra"
)
concepts_str = ", ".join(allowed_concepts) if allowed_concepts else "מונחי אלגברה מדויקים"
sub_q_line = f"\nSUB-QUESTION TO SOLVE NOW: {sub_question_text}" if sub_question_text else ""
return f"""You are a Pedagogical Planner for a math tutoring system.
Your role is to plan HOW to solve a math problem, NOT to solve it yourself.
PROBLEM CONTEXT (AST Metadata):
{json.dumps(ast_metadata, ensure_ascii=False, indent=2)}{sub_q_line}
CRITICAL RULES:
1. You MUST wrap your entire JSON response between <JSON_START> and <JSON_END> tags.
2. Do NOT write any math expressions or compute anything yourself.
3. When referring to AST nodes, use their IDs only (e.g. "ast_node_0").
4. Your response MUST conform to this exact JSON schema:
{{
"pedagogical_rationale": "<short explanation of the teaching strategy in Hebrew>",
"requires_server_compute": [
{{"action": "<ENUM_ACTION>", "target_step_ref": "<ast_node_id>"}},
...
]
}}
5. Allowed actions (ENUM only): SOLVE_EQUATION, SIMPLIFY, FIND_DERIVATIVE, FIND_INTEGRAL, FACTOR, EXPAND, SUBSTITUTE, FIND_AXIS_INTERSECTIONS, CALCULATE_SLOPE_AND_LINE, CALCULATE_DISTANCE.
6. Maximum {MAX_SERVER_STEPS} compute steps.
7. Pedagogical Constraint: {prompt_specialization}
8. Vocabulary: Prioritize these terms: [{concepts_str}].
9. SINGLE-ACTION CONSTRAINT (critical): For complex equations (circles, trigonometry, systems),
always use SOLVE_EQUATION directly on ast_node_0. DO NOT chain operations that reference
intermediate result nodes (ast_node_1, ast_node_2, etc.) — those nodes do not exist in
the server registry. The SymPy engine handles simplification and expansion internally
as part of SOLVE_EQUATION. Violating this rule causes a server crash.
10. CONTEXTUAL ROUTING (V7.3 — critical): Analyze the SUB-QUESTION TO SOLVE NOW carefully.
Select the action that matches the specific task:
- "חיתוך", "צירים" → FIND_AXIS_INTERSECTIONS
- "ישר", "שיפוע", "משואה" → CALCULATE_SLOPE_AND_LINE
- "מרחק", "נקודה על המעגל" → CALCULATE_DISTANCE
- General algebra/equation → SOLVE_EQUATION
You MUST choose the action that solves specifically this sub-question.
Do NOT use SOLVE_EQUATION when a more specific action applies."""
async def generate_draft_proposal(self, context_obj, prompt_specialization: str, ast_metadata: dict = None) -> dict:
"""
Asks LLM #1 (Planner) to produce a structured action plan.
Returns {"success": True, "plan": PlannerResponse} or {"success": False, "reason": ...}.
"""
if ast_metadata is None:
ast_metadata = {}
system_prompt = self._build_system_prompt(
ast_metadata, prompt_specialization,
sub_question_text=getattr(context_obj, 'sub_question_text', '')
)
user_prompt = (
f"Plan the solution for: {context_obj.math_input}\n"
f"Remember: Output JSON only, wrapped in <JSON_START>...<JSON_END> tags."
)
logger.info(f"[PLANNER] Requesting plan for: {context_obj.math_input}")
for attempt in range(2): # Max 1 retry (Full Strategy Re-run)
try:
raw_response = await self.llm_gateway.generate_raw(system_prompt, user_prompt)
plan = extract_and_validate_plan(raw_response)
logger.info(
f"[PLANNER] Valid plan received on attempt {attempt + 1}. "
f"Actions: {[t.action for t in plan.requires_server_compute]}"
)
# Phase 1 Live: Track which Enum actions the Planner chose (drift detection)
chosen_actions = [task.action.value for task in plan.requires_server_compute]
telemetry.emit_planner_strategy_distribution(chosen_actions)
return {"success": True, "plan": plan}
except PlannerFormatError as e:
logger.warning(f"[PLANNER] Attempt {attempt + 1} failed: {e}")
if attempt == 0:
# Full Strategy Re-run: inject hard feedback and retry
user_prompt = (
f"Your previous response was invalid: {e}\n"
f"Try again. Plan the solution for: {context_obj.math_input}\n"
f"You MUST use <JSON_START> and <JSON_END> tags. Output valid JSON only."
)
continue
else:
logger.error("[PLANNER] Max retries exhausted. Failing closed.")
return {"success": False, "reason": str(e)}
except Exception as e:
logger.error(f"[PLANNER] LLM failure on attempt {attempt + 1}: {e}")
if attempt == 0:
continue
return {"success": False, "reason": f"LLM_FAILURE: {e}"}
return {"success": False, "reason": "MAX_RETRIES_EXHAUSTED"}
|