github-actions[bot] commited on
Commit
2dafef8
·
1 Parent(s): 6549dce

deploy: backend from aae52f5

Browse files
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 = json.loads(raw[json_start:json_end])
 
 
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 = json.loads(topic_text[json_start:json_end])
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 = json.loads(cleaned[start:end])
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 = json.loads(text)
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 = json.loads(json_match.group())
13894
  else:
13895
- lesson_data = json.loads(response)
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 = json.loads(json_match.group())
14310
  else:
14311
- raw_items = json.loads(items_json) if items_json.strip().startswith('[') else []
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 = json.loads(json_match.group())
14517
  else:
14518
- lesson_data = json.loads(response_text)
 
 
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 = json.loads(raw_response)
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
- try:
99
- return json.loads(cleaned)
100
- except json.JSONDecodeError:
101
- # Try to find a JSON object in the string
102
- try:
103
- start_idx = cleaned.find('{')
104
- end_idx = cleaned.rfind('}')
105
- if start_idx != -1 and end_idx != -1 and end_idx >= start_idx:
106
- json_str = cleaned[start_idx:end_idx + 1]
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 = _json.loads(response_text)
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
- return DiagnosticAnalysisResponse(success=True, analysis=cache_doc.to_dict())
 
 
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 = json.loads(cleaned)
 
 
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-v2"
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
- module_json = json.loads(raw_response[json_start:json_end])
 
 
 
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(candidate)
218
  except json.JSONDecodeError:
219
  return None
220
- return candidate
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
- parsed = json.loads(content or "{}")
 
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 = json.loads(candidate) if candidate else None
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
- insights = json.loads(content or "{}")
 
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
- parsed = sanitize_tree(json.loads(content or "{}"))
 
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
- parsed = json.loads(content or "[]")
 
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
- segment = segment.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", " ")
 
 
 
 
 
 
 
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
- parsed = json.loads(content or "{}")
 
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 = json.loads(content)
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
  [