Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
2dafef8
1
Parent(s): 6549dce
deploy: backend from aae52f5
Browse files- main.py +20 -12
- rag/pdf_ingestion.py +2 -1
- rag/teacher_module_generator.py +11 -13
- routes/class_records_router.py +2 -3
- routes/diagnostic.py +13 -5
- routes/rag_routes.py +1 -1
- routes/teacher_materials.py +5 -2
- services/ai_client.py +6 -2
- services/class_analytics_engine.py +2 -1
- services/inference_client.py +2 -1
- services/intervention_engine.py +6 -3
- services/llm_json.py +37 -0
- services/llm_text.py +10 -1
- services/student_intelligence_pipeline.py +2 -1
- services/variance_engine.py +2 -1
- tests/test_llm_json.py +36 -0
- tests/test_llm_text.py +7 -0
- tests/test_parse_json_response.py +16 -0
main.py
CHANGED
|
@@ -3525,7 +3525,9 @@ Respond with ONLY a JSON object (no markdown, no explanation outside the JSON):
|
|
| 3525 |
json_start = raw.find("{")
|
| 3526 |
json_end = raw.rfind("}") + 1
|
| 3527 |
if json_start >= 0 and json_end > json_start:
|
| 3528 |
-
parsed =
|
|
|
|
|
|
|
| 3529 |
else:
|
| 3530 |
logger.warning(f"LLM judge returned non-JSON: {raw[:200]}")
|
| 3531 |
return {"correct": False, "issues": ["Could not parse judge response"], "confidence": 0.0}
|
|
@@ -9135,7 +9137,7 @@ TEXT:
|
|
| 9135 |
json_end = topic_text.rfind("}") + 1
|
| 9136 |
topic_payload: Dict[str, Any] = {}
|
| 9137 |
if json_start >= 0 and json_end > json_start:
|
| 9138 |
-
topic_payload =
|
| 9139 |
|
| 9140 |
for idx, topic in enumerate((topic_payload.get("topics") or [])[:10]):
|
| 9141 |
title = str(topic.get("title", "")).strip()
|
|
@@ -10139,7 +10141,7 @@ def _parse_lesson_plan_json(raw: str) -> Dict[str, Any]:
|
|
| 10139 |
end = cleaned.rfind("}") + 1
|
| 10140 |
if start >= 0 and end > start:
|
| 10141 |
try:
|
| 10142 |
-
parsed =
|
| 10143 |
if isinstance(parsed, dict):
|
| 10144 |
return parsed
|
| 10145 |
except Exception:
|
|
@@ -11706,7 +11708,7 @@ def _validate_quiz_questions(
|
|
| 11706 |
return []
|
| 11707 |
if text.startswith("[") and text.endswith("]"):
|
| 11708 |
try:
|
| 11709 |
-
parsed =
|
| 11710 |
if isinstance(parsed, list):
|
| 11711 |
return [str(item).strip() for item in parsed if str(item).strip()]
|
| 11712 |
except Exception:
|
|
@@ -13890,10 +13892,12 @@ Return ONLY this exact JSON (no other text):
|
|
| 13890 |
import re
|
| 13891 |
json_match = re.search(r'\{.*\}', response, re.DOTALL)
|
| 13892 |
if json_match:
|
| 13893 |
-
lesson_data =
|
| 13894 |
else:
|
| 13895 |
-
lesson_data =
|
| 13896 |
-
|
|
|
|
|
|
|
| 13897 |
return DiagnosticLessonResponse(**sanitize_tree(lesson_data))
|
| 13898 |
except Exception as e:
|
| 13899 |
logger.error(f"Diagnostic lesson generation error: {e}")
|
|
@@ -14306,10 +14310,12 @@ Return ONLY this strict JSON array:
|
|
| 14306 |
import re
|
| 14307 |
json_match = re.search(r'\[.*\]', items_json, re.DOTALL)
|
| 14308 |
if json_match:
|
| 14309 |
-
raw_items =
|
| 14310 |
else:
|
| 14311 |
-
raw_items =
|
| 14312 |
-
|
|
|
|
|
|
|
| 14313 |
items: List[AdaptiveQuizItem] = []
|
| 14314 |
for i, qi in enumerate(raw_items[:count]):
|
| 14315 |
qi = sanitize_tree(qi) if isinstance(qi, dict) else qi
|
|
@@ -14513,9 +14519,11 @@ Return as JSON with fields: topic, sections (array of title/content), suggested_
|
|
| 14513 |
# Extract JSON from response
|
| 14514 |
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
|
| 14515 |
if json_match:
|
| 14516 |
-
lesson_data =
|
| 14517 |
else:
|
| 14518 |
-
lesson_data =
|
|
|
|
|
|
|
| 14519 |
|
| 14520 |
lesson_data = sanitize_tree(lesson_data)
|
| 14521 |
return PersonalizedLessonResponse(
|
|
|
|
| 3525 |
json_start = raw.find("{")
|
| 3526 |
json_end = raw.rfind("}") + 1
|
| 3527 |
if json_start >= 0 and json_end > json_start:
|
| 3528 |
+
parsed = loads_jsonish(raw[json_start:json_end])
|
| 3529 |
+
if not isinstance(parsed, dict):
|
| 3530 |
+
raise ValueError("Could not parse judge response")
|
| 3531 |
else:
|
| 3532 |
logger.warning(f"LLM judge returned non-JSON: {raw[:200]}")
|
| 3533 |
return {"correct": False, "issues": ["Could not parse judge response"], "confidence": 0.0}
|
|
|
|
| 9137 |
json_end = topic_text.rfind("}") + 1
|
| 9138 |
topic_payload: Dict[str, Any] = {}
|
| 9139 |
if json_start >= 0 and json_end > json_start:
|
| 9140 |
+
topic_payload = loads_jsonish(topic_text[json_start:json_end]) or {}
|
| 9141 |
|
| 9142 |
for idx, topic in enumerate((topic_payload.get("topics") or [])[:10]):
|
| 9143 |
title = str(topic.get("title", "")).strip()
|
|
|
|
| 10141 |
end = cleaned.rfind("}") + 1
|
| 10142 |
if start >= 0 and end > start:
|
| 10143 |
try:
|
| 10144 |
+
parsed = loads_jsonish(cleaned[start:end])
|
| 10145 |
if isinstance(parsed, dict):
|
| 10146 |
return parsed
|
| 10147 |
except Exception:
|
|
|
|
| 11708 |
return []
|
| 11709 |
if text.startswith("[") and text.endswith("]"):
|
| 11710 |
try:
|
| 11711 |
+
parsed = loads_jsonish(text)
|
| 11712 |
if isinstance(parsed, list):
|
| 11713 |
return [str(item).strip() for item in parsed if str(item).strip()]
|
| 11714 |
except Exception:
|
|
|
|
| 13892 |
import re
|
| 13893 |
json_match = re.search(r'\{.*\}', response, re.DOTALL)
|
| 13894 |
if json_match:
|
| 13895 |
+
lesson_data = loads_jsonish(json_match.group())
|
| 13896 |
else:
|
| 13897 |
+
lesson_data = loads_jsonish(response)
|
| 13898 |
+
if lesson_data is None:
|
| 13899 |
+
raise ValueError("Could not parse lesson JSON from model response")
|
| 13900 |
+
|
| 13901 |
return DiagnosticLessonResponse(**sanitize_tree(lesson_data))
|
| 13902 |
except Exception as e:
|
| 13903 |
logger.error(f"Diagnostic lesson generation error: {e}")
|
|
|
|
| 14310 |
import re
|
| 14311 |
json_match = re.search(r'\[.*\]', items_json, re.DOTALL)
|
| 14312 |
if json_match:
|
| 14313 |
+
raw_items = loads_jsonish(json_match.group())
|
| 14314 |
else:
|
| 14315 |
+
raw_items = loads_jsonish(items_json) if items_json.strip().startswith('[') else []
|
| 14316 |
+
if raw_items is None:
|
| 14317 |
+
raise ValueError("Could not parse quiz items JSON from model response")
|
| 14318 |
+
|
| 14319 |
items: List[AdaptiveQuizItem] = []
|
| 14320 |
for i, qi in enumerate(raw_items[:count]):
|
| 14321 |
qi = sanitize_tree(qi) if isinstance(qi, dict) else qi
|
|
|
|
| 14519 |
# Extract JSON from response
|
| 14520 |
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
|
| 14521 |
if json_match:
|
| 14522 |
+
lesson_data = loads_jsonish(json_match.group())
|
| 14523 |
else:
|
| 14524 |
+
lesson_data = loads_jsonish(response_text)
|
| 14525 |
+
if lesson_data is None:
|
| 14526 |
+
raise json.JSONDecodeError("Unparseable lesson JSON", response_text, 0)
|
| 14527 |
|
| 14528 |
lesson_data = sanitize_tree(lesson_data)
|
| 14529 |
return PersonalizedLessonResponse(
|
rag/pdf_ingestion.py
CHANGED
|
@@ -24,6 +24,7 @@ import pypdf
|
|
| 24 |
|
| 25 |
from rag.firebase_storage_loader import _init_firebase_storage
|
| 26 |
from services.ai_client import complete_text, get_anthropic_client, CHAT_MODEL
|
|
|
|
| 27 |
|
| 28 |
logger = logging.getLogger(__name__)
|
| 29 |
|
|
@@ -96,7 +97,7 @@ Return a JSON array only, no extra text."""
|
|
| 96 |
json_output=True,
|
| 97 |
timeout=120.0,
|
| 98 |
)
|
| 99 |
-
questions =
|
| 100 |
return questions if isinstance(questions, list) else []
|
| 101 |
except json.JSONDecodeError as e:
|
| 102 |
logger.error(f"Failed to parse Claude response as JSON for chunk {chunk_id}: {e}")
|
|
|
|
| 24 |
|
| 25 |
from rag.firebase_storage_loader import _init_firebase_storage
|
| 26 |
from services.ai_client import complete_text, get_anthropic_client, CHAT_MODEL
|
| 27 |
+
from services.llm_json import loads_jsonish
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
|
|
|
|
| 97 |
json_output=True,
|
| 98 |
timeout=120.0,
|
| 99 |
)
|
| 100 |
+
questions = loads_jsonish(raw_response)
|
| 101 |
return questions if isinstance(questions, list) else []
|
| 102 |
except json.JSONDecodeError as e:
|
| 103 |
logger.error(f"Failed to parse Claude response as JSON for chunk {chunk_id}: {e}")
|
rag/teacher_module_generator.py
CHANGED
|
@@ -6,6 +6,8 @@ import logging
|
|
| 6 |
import uuid
|
| 7 |
from datetime import datetime
|
| 8 |
|
|
|
|
|
|
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
class ExampleItem(BaseModel):
|
|
@@ -95,19 +97,15 @@ def _parse_module_json(raw: str) -> Optional[Dict[str, Any]]:
|
|
| 95 |
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", cleaned, flags=re.IGNORECASE)
|
| 96 |
cleaned = cleaned.strip()
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
return json.loads(json_str)
|
| 108 |
-
except Exception:
|
| 109 |
-
pass
|
| 110 |
-
return None
|
| 111 |
|
| 112 |
async def generate_teacher_module(
|
| 113 |
course_material_text: str,
|
|
|
|
| 6 |
import uuid
|
| 7 |
from datetime import datetime
|
| 8 |
|
| 9 |
+
from services.llm_json import loads_jsonish
|
| 10 |
+
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
class ExampleItem(BaseModel):
|
|
|
|
| 97 |
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", cleaned, flags=re.IGNORECASE)
|
| 98 |
cleaned = cleaned.strip()
|
| 99 |
|
| 100 |
+
parsed = loads_jsonish(cleaned)
|
| 101 |
+
if parsed is not None:
|
| 102 |
+
return parsed
|
| 103 |
+
# Try to find a JSON object in the string
|
| 104 |
+
start_idx = cleaned.find('{')
|
| 105 |
+
end_idx = cleaned.rfind('}')
|
| 106 |
+
if start_idx != -1 and end_idx != -1 and end_idx >= start_idx:
|
| 107 |
+
return loads_jsonish(cleaned[start_idx:end_idx + 1])
|
| 108 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
async def generate_teacher_module(
|
| 111 |
course_material_text: str,
|
routes/class_records_router.py
CHANGED
|
@@ -20,6 +20,7 @@ from fastapi import APIRouter, Form, HTTPException, Query, Request, UploadFile,
|
|
| 20 |
from fastapi.responses import StreamingResponse
|
| 21 |
|
| 22 |
from services.inference_client import call_hf_chat_async
|
|
|
|
| 23 |
from services.wri_service import compute_wri
|
| 24 |
|
| 25 |
logger = logging.getLogger("mathpulse.class_records")
|
|
@@ -725,8 +726,6 @@ async def get_upload_students(
|
|
| 725 |
|
| 726 |
@router.post("/class-records/{uploadId}/ai-report")
|
| 727 |
async def generate_ai_class_report(request: Request, uploadId: str):
|
| 728 |
-
import json as _json
|
| 729 |
-
|
| 730 |
user: Any = getattr(request.state, "user", None)
|
| 731 |
if user is None:
|
| 732 |
raise HTTPException(status_code=401, detail="Authentication required")
|
|
@@ -790,7 +789,7 @@ async def generate_ai_class_report(request: Request, uploadId: str):
|
|
| 790 |
max_tokens=400, task_type="class_report"
|
| 791 |
)
|
| 792 |
try:
|
| 793 |
-
parsed =
|
| 794 |
recommendations = parsed.get("recommendations", [])
|
| 795 |
except Exception:
|
| 796 |
recommendations = [r.strip() for r in response_text.split('\n') if len(r.strip()) > 20][:2]
|
|
|
|
| 20 |
from fastapi.responses import StreamingResponse
|
| 21 |
|
| 22 |
from services.inference_client import call_hf_chat_async
|
| 23 |
+
from services.llm_json import loads_jsonish
|
| 24 |
from services.wri_service import compute_wri
|
| 25 |
|
| 26 |
logger = logging.getLogger("mathpulse.class_records")
|
|
|
|
| 726 |
|
| 727 |
@router.post("/class-records/{uploadId}/ai-report")
|
| 728 |
async def generate_ai_class_report(request: Request, uploadId: str):
|
|
|
|
|
|
|
| 729 |
user: Any = getattr(request.state, "user", None)
|
| 730 |
if user is None:
|
| 731 |
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
| 789 |
max_tokens=400, task_type="class_report"
|
| 790 |
)
|
| 791 |
try:
|
| 792 |
+
parsed = loads_jsonish(response_text)
|
| 793 |
recommendations = parsed.get("recommendations", [])
|
| 794 |
except Exception:
|
| 795 |
recommendations = [r.strip() for r in response_text.split('\n') if len(r.strip()) > 20][:2]
|
routes/diagnostic.py
CHANGED
|
@@ -21,13 +21,17 @@ from fastapi import APIRouter, HTTPException, Request
|
|
| 21 |
from pydantic import BaseModel, Field
|
| 22 |
|
| 23 |
from services.ai_client import complete_text
|
| 24 |
-
from services.llm_json import LLMJsonError, extract_json_value, extract_dict_list
|
| 25 |
from rag.curriculum_rag import retrieve_curriculum_context
|
| 26 |
import firebase_admin
|
| 27 |
from firebase_admin import firestore as fs
|
| 28 |
|
| 29 |
logger = logging.getLogger("mathpulse.diagnostic")
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
router = APIRouter(prefix="/api/diagnostic", tags=["diagnostic"])
|
| 32 |
|
| 33 |
# In-memory fallback session store (used if Firestore is unavailable)
|
|
@@ -904,8 +908,10 @@ async def analyze_diagnostic(request: DiagnosticAnalysisRequest, req: Request):
|
|
| 904 |
# Check server-side cache first
|
| 905 |
cache_ref = firestore_client.collection("diagnosticResults").document(request.user_id).collection("cache").document("analysis")
|
| 906 |
cache_doc = cache_ref.get()
|
| 907 |
-
if cache_doc.exists
|
| 908 |
-
|
|
|
|
|
|
|
| 909 |
|
| 910 |
results_data = results_doc.to_dict() or {}
|
| 911 |
responses = results_data.get("responses", [])
|
|
@@ -1011,7 +1017,9 @@ Return ONLY valid JSON, no markdown fences."""
|
|
| 1011 |
cleaned = cleaned[:-3]
|
| 1012 |
cleaned = cleaned.strip()
|
| 1013 |
|
| 1014 |
-
analysis =
|
|
|
|
|
|
|
| 1015 |
except json.JSONDecodeError as e:
|
| 1016 |
logger.warning(f"[diagnostic/analyze] AI JSON parse failed: {e}, using fallback")
|
| 1017 |
analysis = _build_fallback_analysis(responses, domain_scores, risk_profile)
|
|
@@ -1021,7 +1029,7 @@ Return ONLY valid JSON, no markdown fences."""
|
|
| 1021 |
|
| 1022 |
# Cache the analysis in Firestore for future requests
|
| 1023 |
try:
|
| 1024 |
-
cache_ref.set(analysis)
|
| 1025 |
except Exception as e:
|
| 1026 |
logger.warning(f"[diagnostic/analyze] Failed to cache analysis: {e}")
|
| 1027 |
|
|
|
|
| 21 |
from pydantic import BaseModel, Field
|
| 22 |
|
| 23 |
from services.ai_client import complete_text
|
| 24 |
+
from services.llm_json import LLMJsonError, extract_json_value, extract_dict_list, loads_jsonish
|
| 25 |
from rag.curriculum_rag import retrieve_curriculum_context
|
| 26 |
import firebase_admin
|
| 27 |
from firebase_admin import firestore as fs
|
| 28 |
|
| 29 |
logger = logging.getLogger("mathpulse.diagnostic")
|
| 30 |
|
| 31 |
+
# Bump when cached AI analyses must regenerate (e.g. they were saved with corrupted LaTeX).
|
| 32 |
+
_ANALYSIS_CACHE_VERSION = "2026-10-latex"
|
| 33 |
+
_ANALYSIS_CACHE_VERSION_FIELD = "cacheVersion"
|
| 34 |
+
|
| 35 |
router = APIRouter(prefix="/api/diagnostic", tags=["diagnostic"])
|
| 36 |
|
| 37 |
# In-memory fallback session store (used if Firestore is unavailable)
|
|
|
|
| 908 |
# Check server-side cache first
|
| 909 |
cache_ref = firestore_client.collection("diagnosticResults").document(request.user_id).collection("cache").document("analysis")
|
| 910 |
cache_doc = cache_ref.get()
|
| 911 |
+
cached_analysis = (cache_doc.to_dict() or {}) if cache_doc.exists else {}
|
| 912 |
+
# A version mismatch is a cache miss, so analyses cached before the LaTeX-repair fix regenerate.
|
| 913 |
+
if cached_analysis.pop(_ANALYSIS_CACHE_VERSION_FIELD, None) == _ANALYSIS_CACHE_VERSION:
|
| 914 |
+
return DiagnosticAnalysisResponse(success=True, analysis=cached_analysis)
|
| 915 |
|
| 916 |
results_data = results_doc.to_dict() or {}
|
| 917 |
responses = results_data.get("responses", [])
|
|
|
|
| 1017 |
cleaned = cleaned[:-3]
|
| 1018 |
cleaned = cleaned.strip()
|
| 1019 |
|
| 1020 |
+
analysis = loads_jsonish(cleaned)
|
| 1021 |
+
if analysis is None:
|
| 1022 |
+
raise json.JSONDecodeError("Unparseable analysis JSON", cleaned, 0)
|
| 1023 |
except json.JSONDecodeError as e:
|
| 1024 |
logger.warning(f"[diagnostic/analyze] AI JSON parse failed: {e}, using fallback")
|
| 1025 |
analysis = _build_fallback_analysis(responses, domain_scores, risk_profile)
|
|
|
|
| 1029 |
|
| 1030 |
# Cache the analysis in Firestore for future requests
|
| 1031 |
try:
|
| 1032 |
+
cache_ref.set({**analysis, _ANALYSIS_CACHE_VERSION_FIELD: _ANALYSIS_CACHE_VERSION})
|
| 1033 |
except Exception as e:
|
| 1034 |
logger.warning(f"[diagnostic/analyze] Failed to cache analysis: {e}")
|
| 1035 |
|
routes/rag_routes.py
CHANGED
|
@@ -380,7 +380,7 @@ def _ensure_7_sections(lesson_data: dict, lesson_title: str, chunks: Optional[Li
|
|
| 380 |
# ─── Lesson pipeline: per-student cache, in-flight dedupe, streamed generation ─
|
| 381 |
|
| 382 |
# Bump when build_lesson_prompt or the richness rules change: new keys -> lessons regenerate on next open.
|
| 383 |
-
LESSON_PROMPT_VERSION = "2026-10-
|
| 384 |
_LESSON_MEMORY_MAX_ENTRIES = 256
|
| 385 |
_RETRIEVAL_MEMORY_MAX_ENTRIES = 256
|
| 386 |
_LESSON_GENERATION_CAP_SEC = 300.0
|
|
|
|
| 380 |
# ─── Lesson pipeline: per-student cache, in-flight dedupe, streamed generation ─
|
| 381 |
|
| 382 |
# Bump when build_lesson_prompt or the richness rules change: new keys -> lessons regenerate on next open.
|
| 383 |
+
LESSON_PROMPT_VERSION = "2026-10-v3"
|
| 384 |
_LESSON_MEMORY_MAX_ENTRIES = 256
|
| 385 |
_RETRIEVAL_MEMORY_MAX_ENTRIES = 256
|
| 386 |
_LESSON_GENERATION_CAP_SEC = 300.0
|
routes/teacher_materials.py
CHANGED
|
@@ -26,7 +26,7 @@ from fastapi import File, Form, UploadFile
|
|
| 26 |
from pydantic import BaseModel, Field
|
| 27 |
|
| 28 |
from services.ai_client import CHAT_MODEL
|
| 29 |
-
from services.llm_json import extract_dict_list, extract_json_value
|
| 30 |
|
| 31 |
logger = logging.getLogger("mathpulse.teacher_materials")
|
| 32 |
|
|
@@ -338,7 +338,10 @@ Respond with JSON only, no markdown or extra text."""
|
|
| 338 |
if json_start < 0 or json_end <= json_start:
|
| 339 |
raise ValueError(f"Could not find JSON in AI response: {raw_response[:200]}")
|
| 340 |
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
| 342 |
return module_json
|
| 343 |
|
| 344 |
except json.JSONDecodeError as e:
|
|
|
|
| 26 |
from pydantic import BaseModel, Field
|
| 27 |
|
| 28 |
from services.ai_client import CHAT_MODEL
|
| 29 |
+
from services.llm_json import extract_dict_list, extract_json_value, loads_jsonish
|
| 30 |
|
| 31 |
logger = logging.getLogger("mathpulse.teacher_materials")
|
| 32 |
|
|
|
|
| 338 |
if json_start < 0 or json_end <= json_start:
|
| 339 |
raise ValueError(f"Could not find JSON in AI response: {raw_response[:200]}")
|
| 340 |
|
| 341 |
+
module_span = raw_response[json_start:json_end]
|
| 342 |
+
module_json = loads_jsonish(module_span)
|
| 343 |
+
if module_json is None:
|
| 344 |
+
raise json.JSONDecodeError("Unparseable module JSON", module_span, 0)
|
| 345 |
return module_json
|
| 346 |
|
| 347 |
except json.JSONDecodeError as e:
|
services/ai_client.py
CHANGED
|
@@ -18,6 +18,8 @@ from anthropic import (
|
|
| 18 |
from anthropic.lib.streaming import BetaMessageStreamManager
|
| 19 |
from anthropic.types.beta import BetaMessage
|
| 20 |
|
|
|
|
|
|
|
| 21 |
try:
|
| 22 |
from dotenv import load_dotenv
|
| 23 |
for _env_path in [
|
|
@@ -213,11 +215,13 @@ def outer_json(text: str) -> Optional[str]:
|
|
| 213 |
if not starts or end < min(starts):
|
| 214 |
return None
|
| 215 |
candidate = text[min(starts) : end + 1]
|
|
|
|
|
|
|
| 216 |
try:
|
| 217 |
-
json.loads(
|
| 218 |
except json.JSONDecodeError:
|
| 219 |
return None
|
| 220 |
-
return
|
| 221 |
|
| 222 |
|
| 223 |
def reply_text(message: BetaMessage, *, json_output: bool = False) -> str:
|
|
|
|
| 18 |
from anthropic.lib.streaming import BetaMessageStreamManager
|
| 19 |
from anthropic.types.beta import BetaMessage
|
| 20 |
|
| 21 |
+
from .llm_json import normalize_candidate
|
| 22 |
+
|
| 23 |
try:
|
| 24 |
from dotenv import load_dotenv
|
| 25 |
for _env_path in [
|
|
|
|
| 215 |
if not starts or end < min(starts):
|
| 216 |
return None
|
| 217 |
candidate = text[min(starts) : end + 1]
|
| 218 |
+
# Return the repaired span so the caller's json.loads keeps single-backslash LaTeX (\times, \sqrt) intact.
|
| 219 |
+
repaired = normalize_candidate(candidate)
|
| 220 |
try:
|
| 221 |
+
json.loads(repaired)
|
| 222 |
except json.JSONDecodeError:
|
| 223 |
return None
|
| 224 |
+
return repaired
|
| 225 |
|
| 226 |
|
| 227 |
def reply_text(message: BetaMessage, *, json_output: bool = False) -> str:
|
services/class_analytics_engine.py
CHANGED
|
@@ -481,7 +481,8 @@ Be specific to Filipino K-12 DepEd context. If data is limited, acknowledge it a
|
|
| 481 |
json_output=True,
|
| 482 |
timeout=60.0,
|
| 483 |
)
|
| 484 |
-
|
|
|
|
| 485 |
|
| 486 |
return ClassInsights(
|
| 487 |
class_id=class_id,
|
|
|
|
| 481 |
json_output=True,
|
| 482 |
timeout=60.0,
|
| 483 |
)
|
| 484 |
+
from services.llm_json import loads_jsonish
|
| 485 |
+
parsed = loads_jsonish(content or "{}")
|
| 486 |
|
| 487 |
return ClassInsights(
|
| 488 |
class_id=class_id,
|
services/inference_client.py
CHANGED
|
@@ -52,6 +52,7 @@ from .ai_client import (
|
|
| 52 |
outer_json,
|
| 53 |
reply_text,
|
| 54 |
)
|
|
|
|
| 55 |
from .logging_utils import configure_structured_logging, log_model_call
|
| 56 |
|
| 57 |
LOGGER = configure_structured_logging("mathpulse.inference")
|
|
@@ -956,7 +957,7 @@ def parse_json_response(text: Optional[str]) -> Optional[dict]:
|
|
| 956 |
if not text:
|
| 957 |
return None
|
| 958 |
candidate = outer_json(text)
|
| 959 |
-
parsed =
|
| 960 |
if not isinstance(parsed, dict):
|
| 961 |
LOGGER.warning("[CLAUDE] Failed to parse JSON response")
|
| 962 |
return None
|
|
|
|
| 52 |
outer_json,
|
| 53 |
reply_text,
|
| 54 |
)
|
| 55 |
+
from .llm_json import loads_jsonish
|
| 56 |
from .logging_utils import configure_structured_logging, log_model_call
|
| 57 |
|
| 58 |
LOGGER = configure_structured_logging("mathpulse.inference")
|
|
|
|
| 957 |
if not text:
|
| 958 |
return None
|
| 959 |
candidate = outer_json(text)
|
| 960 |
+
parsed = loads_jsonish(candidate) if candidate else None
|
| 961 |
if not isinstance(parsed, dict):
|
| 962 |
LOGGER.warning("[CLAUDE] Failed to parse JSON response")
|
| 963 |
return None
|
services/intervention_engine.py
CHANGED
|
@@ -431,7 +431,8 @@ Return as JSON:
|
|
| 431 |
json_output=True,
|
| 432 |
timeout=60.0,
|
| 433 |
)
|
| 434 |
-
|
|
|
|
| 435 |
return {k: to_plain_text(v, strip_bullets=False) if isinstance(v, str) else v for k, v in insights.items()}
|
| 436 |
except Exception as e:
|
| 437 |
logger.warning(f"Claude insights failed: {e}")
|
|
@@ -504,7 +505,8 @@ Return ONLY valid JSON:
|
|
| 504 |
json_output=True,
|
| 505 |
timeout=60.0,
|
| 506 |
)
|
| 507 |
-
|
|
|
|
| 508 |
|
| 509 |
steps = []
|
| 510 |
for s in parsed.get("steps", []):
|
|
@@ -586,7 +588,8 @@ Return as a JSON array of strings. Each recommendation max 25 words. Be specific
|
|
| 586 |
json_output=True,
|
| 587 |
timeout=60.0,
|
| 588 |
)
|
| 589 |
-
|
|
|
|
| 590 |
if isinstance(parsed, dict):
|
| 591 |
parsed = parsed.get("recommendations", parsed.get("actions", []))
|
| 592 |
if isinstance(parsed, list):
|
|
|
|
| 431 |
json_output=True,
|
| 432 |
timeout=60.0,
|
| 433 |
)
|
| 434 |
+
from services.llm_json import loads_jsonish
|
| 435 |
+
insights = loads_jsonish(content or "{}")
|
| 436 |
return {k: to_plain_text(v, strip_bullets=False) if isinstance(v, str) else v for k, v in insights.items()}
|
| 437 |
except Exception as e:
|
| 438 |
logger.warning(f"Claude insights failed: {e}")
|
|
|
|
| 505 |
json_output=True,
|
| 506 |
timeout=60.0,
|
| 507 |
)
|
| 508 |
+
from services.llm_json import loads_jsonish
|
| 509 |
+
parsed = sanitize_tree(loads_jsonish(content or "{}"))
|
| 510 |
|
| 511 |
steps = []
|
| 512 |
for s in parsed.get("steps", []):
|
|
|
|
| 588 |
json_output=True,
|
| 589 |
timeout=60.0,
|
| 590 |
)
|
| 591 |
+
from services.llm_json import loads_jsonish
|
| 592 |
+
parsed = loads_jsonish(content or "[]")
|
| 593 |
if isinstance(parsed, dict):
|
| 594 |
parsed = parsed.get("recommendations", parsed.get("actions", []))
|
| 595 |
if isinstance(parsed, list):
|
services/llm_json.py
CHANGED
|
@@ -22,6 +22,7 @@ __all__ = [
|
|
| 22 |
"strip_reasoning",
|
| 23 |
"extract_json_blocks",
|
| 24 |
"normalize_candidate",
|
|
|
|
| 25 |
"loads_jsonish",
|
| 26 |
"extract_json_value",
|
| 27 |
"extract_json_object",
|
|
@@ -88,6 +89,41 @@ def extract_json_blocks(text: str) -> List[str]:
|
|
| 88 |
return blocks
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
def normalize_candidate(candidate: str) -> str:
|
| 92 |
"""Repair the defects models introduce into otherwise valid JSON."""
|
| 93 |
normalized = candidate.strip().lstrip("\ufeff")
|
|
@@ -100,6 +136,7 @@ def normalize_candidate(candidate: str) -> str:
|
|
| 100 |
.replace("\u2018", "'")
|
| 101 |
.replace("\u2019", "'")
|
| 102 |
)
|
|
|
|
| 103 |
# Drop trailing commas before an object/array closer.
|
| 104 |
return re.sub(r",(\s*[}\]])", r"\1", normalized)
|
| 105 |
|
|
|
|
| 22 |
"strip_reasoning",
|
| 23 |
"extract_json_blocks",
|
| 24 |
"normalize_candidate",
|
| 25 |
+
"repair_latex_backslashes",
|
| 26 |
"loads_jsonish",
|
| 27 |
"extract_json_value",
|
| 28 |
"extract_json_object",
|
|
|
|
| 89 |
return blocks
|
| 90 |
|
| 91 |
|
| 92 |
+
# TeX commands that start with ``n``; any other ``\n<letters>`` is a newline
|
| 93 |
+
# followed by prose ("\nThe next step").
|
| 94 |
+
TEX_N_COMMANDS = frozenset(
|
| 95 |
+
{
|
| 96 |
+
"nabla", "natural", "nearrow", "neg", "neq", "newline", "ngeq", "ngtr",
|
| 97 |
+
"ni", "nleq", "nless", "nmid", "not", "notin", "nparallel", "nsubseteq",
|
| 98 |
+
"nsupseteq", "nu", "nwarrow",
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
_BACKSLASH_ESCAPE_RE = re.compile(r"\\(u[0-9a-fA-F]{4}|.)([A-Za-z]*)", re.DOTALL)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _repair_backslash(match: "re.Match[str]") -> str:
|
| 105 |
+
escape, letters = match.group(1), match.group(2)
|
| 106 |
+
whole = match.group(0)
|
| 107 |
+
if escape in ('"', "\\", "/") or len(escape) == 5:
|
| 108 |
+
return whole
|
| 109 |
+
if escape == "n":
|
| 110 |
+
return "\\" + whole if escape + letters in TEX_N_COMMANDS else whole
|
| 111 |
+
if escape in "bfrt" and not letters:
|
| 112 |
+
return whole
|
| 113 |
+
# A TeX command (\times, \text, \frac, \beta, \right) or an escape JSON
|
| 114 |
+
# rejects (\sqrt, \cdot): keep the backslash literal.
|
| 115 |
+
return "\\" + whole
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def repair_latex_backslashes(candidate: str) -> str:
|
| 119 |
+
r"""Double single backslashes that start LaTeX commands inside JSON text.
|
| 120 |
+
|
| 121 |
+
Models write ``\times`` where JSON needs ``\\times``; ``json.loads`` would
|
| 122 |
+
decode the ``\t`` as a TAB and the lesson would show "imes".
|
| 123 |
+
"""
|
| 124 |
+
return _BACKSLASH_ESCAPE_RE.sub(_repair_backslash, candidate)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
def normalize_candidate(candidate: str) -> str:
|
| 128 |
"""Repair the defects models introduce into otherwise valid JSON."""
|
| 129 |
normalized = candidate.strip().lstrip("\ufeff")
|
|
|
|
| 136 |
.replace("\u2018", "'")
|
| 137 |
.replace("\u2019", "'")
|
| 138 |
)
|
| 139 |
+
normalized = repair_latex_backslashes(normalized)
|
| 140 |
# Drop trailing commas before an object/array closer.
|
| 141 |
return re.sub(r",(\s*[}\]])", r"\1", normalized)
|
| 142 |
|
services/llm_text.py
CHANGED
|
@@ -21,6 +21,8 @@ from __future__ import annotations
|
|
| 21 |
import re
|
| 22 |
from typing import Any, Callable, Iterable, Mapping, Optional
|
| 23 |
|
|
|
|
|
|
|
| 24 |
__all__ = [
|
| 25 |
"clean_llm_text",
|
| 26 |
"to_plain_text",
|
|
@@ -111,7 +113,14 @@ def _outside_math(text: str, fn: Callable[[str], str]) -> str:
|
|
| 111 |
|
| 112 |
def _fix_escapes(segment: str) -> str:
|
| 113 |
# JSON-escaped newlines/tabs that survived parsing as literal text.
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
# Double-escaped TeX from over-escaped JSON strings: \\frac → \frac
|
| 116 |
segment = re.sub(r"\\\\(?=[A-Za-z])", r"\\", segment)
|
| 117 |
# Escaped dollars in front of TeX commands block the math delimiter.
|
|
|
|
| 21 |
import re
|
| 22 |
from typing import Any, Callable, Iterable, Mapping, Optional
|
| 23 |
|
| 24 |
+
from services.llm_json import TEX_N_COMMANDS
|
| 25 |
+
|
| 26 |
__all__ = [
|
| 27 |
"clean_llm_text",
|
| 28 |
"to_plain_text",
|
|
|
|
| 113 |
|
| 114 |
def _fix_escapes(segment: str) -> str:
|
| 115 |
# JSON-escaped newlines/tabs that survived parsing as literal text.
|
| 116 |
+
# A letter run after \n or \t can be a TeX command (\neq, \times): keep those.
|
| 117 |
+
segment = segment.replace("\\r\\n", "\n")
|
| 118 |
+
segment = re.sub(
|
| 119 |
+
r"\\n([A-Za-z]*)",
|
| 120 |
+
lambda m: m.group(0) if "n" + m.group(1) in TEX_N_COMMANDS else "\n" + m.group(1),
|
| 121 |
+
segment,
|
| 122 |
+
)
|
| 123 |
+
segment = re.sub(r"\\t(?![A-Za-z])", " ", segment)
|
| 124 |
# Double-escaped TeX from over-escaped JSON strings: \\frac → \frac
|
| 125 |
segment = re.sub(r"\\\\(?=[A-Za-z])", r"\\", segment)
|
| 126 |
# Escaped dollars in front of TeX commands block the math delimiter.
|
services/student_intelligence_pipeline.py
CHANGED
|
@@ -512,7 +512,8 @@ Generate JSON:
|
|
| 512 |
json_output=True,
|
| 513 |
timeout=60.0,
|
| 514 |
)
|
| 515 |
-
|
|
|
|
| 516 |
parsed["generated_at"] = _now_iso()
|
| 517 |
parsed["based_on_wri_status"] = profile.get("risk_status", "pending_assessment")
|
| 518 |
return parsed
|
|
|
|
| 512 |
json_output=True,
|
| 513 |
timeout=60.0,
|
| 514 |
)
|
| 515 |
+
from services.llm_json import loads_jsonish
|
| 516 |
+
parsed = loads_jsonish(content or "{}")
|
| 517 |
parsed["generated_at"] = _now_iso()
|
| 518 |
parsed["based_on_wri_status"] = profile.get("risk_status", "pending_assessment")
|
| 519 |
return parsed
|
services/variance_engine.py
CHANGED
|
@@ -12,6 +12,7 @@ import random
|
|
| 12 |
from typing import List, Dict
|
| 13 |
|
| 14 |
from services.ai_client import complete_text, CHAT_MODEL
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger("mathpulse.variance_engine")
|
| 17 |
from services.question_bank_service import get_cached_session, cache_session_questions
|
|
@@ -91,7 +92,7 @@ Do NOT change "topic", "difficulty", "grade_level", or "source_chunk_id"."""
|
|
| 91 |
json_output=True,
|
| 92 |
timeout=120.0,
|
| 93 |
)
|
| 94 |
-
varied_questions =
|
| 95 |
|
| 96 |
if not isinstance(varied_questions, list) or len(varied_questions) != len(questions):
|
| 97 |
raise ValueError("Invalid response format from Claude")
|
|
|
|
| 12 |
from typing import List, Dict
|
| 13 |
|
| 14 |
from services.ai_client import complete_text, CHAT_MODEL
|
| 15 |
+
from services.llm_json import loads_jsonish
|
| 16 |
|
| 17 |
logger = logging.getLogger("mathpulse.variance_engine")
|
| 18 |
from services.question_bank_service import get_cached_session, cache_session_questions
|
|
|
|
| 92 |
json_output=True,
|
| 93 |
timeout=120.0,
|
| 94 |
)
|
| 95 |
+
varied_questions = loads_jsonish(content)
|
| 96 |
|
| 97 |
if not isinstance(varied_questions, list) or len(varied_questions) != len(questions):
|
| 98 |
raise ValueError("Invalid response format from Claude")
|
tests/test_llm_json.py
CHANGED
|
@@ -140,3 +140,39 @@ class TestCollectDictObjects:
|
|
| 140 |
|
| 141 |
def test_ignores_arrays(self) -> None:
|
| 142 |
assert collect_dict_objects("[1, 2]") == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
def test_ignores_arrays(self) -> None:
|
| 142 |
assert collect_dict_objects("[1, 2]") == []
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class TestLatexBackslashes:
|
| 146 |
+
"""Models often emit single-backslash LaTeX inside JSON strings."""
|
| 147 |
+
|
| 148 |
+
@pytest.mark.parametrize(
|
| 149 |
+
"tex",
|
| 150 |
+
[
|
| 151 |
+
r"G = r \times d",
|
| 152 |
+
r"\text{Profit} = S - C",
|
| 153 |
+
r"\frac{1}{2}",
|
| 154 |
+
r"\beta + \rho",
|
| 155 |
+
r"x \neq 0",
|
| 156 |
+
r"\nabla f",
|
| 157 |
+
r"\left( x \right)",
|
| 158 |
+
r"\sqrt{x} \cdot y",
|
| 159 |
+
r"\underline{x} \le 3",
|
| 160 |
+
],
|
| 161 |
+
)
|
| 162 |
+
def test_single_backslash_tex_survives(self, tex: str) -> None:
|
| 163 |
+
raw = '{"body": "Use $' + tex + '$ here."}'
|
| 164 |
+
assert extract_json_object(raw) == {"body": f"Use ${tex}$ here."}
|
| 165 |
+
|
| 166 |
+
def test_correctly_escaped_tex_is_unchanged(self) -> None:
|
| 167 |
+
raw = r'{"body": "$\\times$ and $\\frac{a}{b}$"}'
|
| 168 |
+
assert extract_json_object(raw) == {"body": r"$\times$ and $\frac{a}{b}$"}
|
| 169 |
+
|
| 170 |
+
def test_real_json_escapes_still_decode(self) -> None:
|
| 171 |
+
raw = r'{"body": "Line one\nThe next line\n\nSay \"hi\" ₱ a\/b \\ end"}'
|
| 172 |
+
assert extract_json_object(raw) == {
|
| 173 |
+
"body": 'Line one\nThe next line\n\nSay "hi" ₱ a/b \\ end'
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
def test_newline_before_prose_word_stays_newline(self) -> None:
|
| 177 |
+
raw = r'{"body": "Step 1\nNote that\nnext"}'
|
| 178 |
+
assert extract_json_object(raw) == {"body": "Step 1\nNote that\nnext"}
|
tests/test_llm_text.py
CHANGED
|
@@ -17,6 +17,13 @@ class TestCleanLlmText:
|
|
| 17 |
raw = "Step 1\\nStep 2 uses \\(x^2\\) and \\[a = b\\]"
|
| 18 |
assert clean_llm_text(raw) == "Step 1\nStep 2 uses $x^2$ and $$a = b$$"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def test_double_escaped_tex_unescaped(self):
|
| 21 |
assert clean_llm_text("Use $\\\\frac{1}{2}$") == "Use $\\frac{1}{2}$"
|
| 22 |
|
|
|
|
| 17 |
raw = "Step 1\\nStep 2 uses \\(x^2\\) and \\[a = b\\]"
|
| 18 |
assert clean_llm_text(raw) == "Step 1\nStep 2 uses $x^2$ and $$a = b$$"
|
| 19 |
|
| 20 |
+
def test_tex_commands_starting_with_n_t_r_survive(self):
|
| 21 |
+
raw = r"$G = r \times d$, $\text{Profit} = S - C$, $a \neq b$, $\nabla f$, $\theta \to \rho$"
|
| 22 |
+
assert clean_llm_text(raw) == raw
|
| 23 |
+
|
| 24 |
+
def test_literal_escapes_before_prose_still_decode(self):
|
| 25 |
+
assert clean_llm_text(r"Step 1\nThe next\n\nnext\t2") == "Step 1\nThe next\n\nnext 2"
|
| 26 |
+
|
| 27 |
def test_double_escaped_tex_unescaped(self):
|
| 28 |
assert clean_llm_text("Use $\\\\frac{1}{2}$") == "Use $\\frac{1}{2}$"
|
| 29 |
|
tests/test_parse_json_response.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
"""parse_json_response feeds weakness detection and the at-risk brief; a reply it can't read falls back silently."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from services.inference_client import parse_json_response
|
|
@@ -20,6 +22,20 @@ def test_recovers_the_json_object(reply: str) -> None:
|
|
| 20 |
assert parse_json_response(reply) == FLAGGED
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
@pytest.mark.parametrize(
|
| 24 |
"reply",
|
| 25 |
[
|
|
|
|
| 1 |
"""parse_json_response feeds weakness detection and the at-risk brief; a reply it can't read falls back silently."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
import pytest
|
| 6 |
|
| 7 |
from services.inference_client import parse_json_response
|
|
|
|
| 22 |
assert parse_json_response(reply) == FLAGGED
|
| 23 |
|
| 24 |
|
| 25 |
+
def test_keeps_single_backslash_latex() -> None:
|
| 26 |
+
# \t, \f decode as control characters and \s, \c are invalid JSON escapes unless repaired.
|
| 27 |
+
reply = r'Result: {"a": "$\sqrt{x} \times 2 \frac{1}{2}$"}'
|
| 28 |
+
assert parse_json_response(reply) == {"a": r"$\sqrt{x} \times 2 \frac{1}{2}$"}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_outer_json_returns_repaired_span() -> None:
|
| 32 |
+
from services.ai_client import outer_json
|
| 33 |
+
|
| 34 |
+
span = outer_json(r'text {"a": "$\sqrt{x} \times 2$"} end')
|
| 35 |
+
assert span is not None
|
| 36 |
+
assert json.loads(span) == {"a": r"$\sqrt{x} \times 2$"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
@pytest.mark.parametrize(
|
| 40 |
"reply",
|
| 41 |
[
|