github-actions[bot] commited on
Commit
e388cbf
·
1 Parent(s): ee201de

deploy: backend from b8e1988

Browse files
main.py CHANGED
@@ -350,6 +350,10 @@ CHAT_STREAM_CONTINUATION_TAIL_CHARS = max(
350
  80,
351
  int(os.getenv("CHAT_STREAM_CONTINUATION_TAIL_CHARS", "900")),
352
  )
 
 
 
 
353
  CHAT_STREAM_COMPLETION_MODE_DEFAULT = os.getenv(
354
  "CHAT_STREAM_COMPLETION_MODE_DEFAULT",
355
  "auto",
@@ -2421,6 +2425,27 @@ async def root():
2421
  # ─── AI Chat Tutor ─────────────────────────────────────────────
2422
 
2423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2424
  MATH_TUTOR_SYSTEM_PROMPT = """You are Pulse, MathPulse AI's friendly math tutor for Filipino Senior High School
2425
  students. You help students understand and solve problems in General Mathematics,
2426
  Business Mathematics, Statistics & Probability, and Finite Mathematics, all aligned
@@ -2429,6 +2454,7 @@ with the DepEd Strengthened SHS Curriculum and SDO Navotas learning modules.
2429
  YOUR BEHAVIOR RULES:
2430
  1. PERSONALIZE every response. Address the student by first name occasionally.
2431
  2. NEVER give direct answers to quiz or exam items — guide with hints and questions instead.
 
2432
  3. If the student is struggling on a critical gap topic, gently steer them back to
2433
  prerequisite concepts before moving forward.
2434
  4. Use the SDO Navotas step-by-step method for ALL solutions:
@@ -2709,13 +2735,14 @@ def _build_stream_continuation_prompt(original_question: str, expected_end_marke
2709
 
2710
  @app.post("/api/chat", response_model=ChatResponse)
2711
  async def chat_tutor_endpoint(request: Request, chat_request: ChatRequest):
2712
- return await chat_tutor(chat_request)
2713
 
2714
 
2715
- async def chat_tutor(request: ChatRequest):
2716
  """AI Math Tutor powered by Hugging Face Inference routing."""
2717
  _start_ms = int(time.monotonic() * 1000)
2718
  try:
 
2719
  # ─── Context-Aware Intent Gate (before scope check) ──────
2720
  # Load active state to determine if student is mid-session.
2721
  # If so, skip scope check for short/vague replies.
@@ -2912,11 +2939,12 @@ async def _update_memory_after_response(
2912
 
2913
  @app.post("/api/chat/stream")
2914
  async def chat_tutor_stream_endpoint(request: Request, chat_request: ChatRequest):
2915
- return await chat_tutor_stream(chat_request)
2916
 
2917
 
2918
- async def chat_tutor_stream(request: ChatRequest):
2919
  """SSE stream endpoint for AI Math Tutor chat responses."""
 
2920
  try:
2921
  # ─── Context-Aware Intent Gate (before scope check) ──────
2922
  _skip_scope_check = False
@@ -5333,6 +5361,23 @@ def _generate_temporary_password(length: int = 12) -> str:
5333
  return candidate
5334
 
5335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5336
  def _parse_provisioning_dataframe(df: Any) -> Dict[str, Any]:
5337
  header_map: Dict[str, str] = {}
5338
  for column in df.columns.tolist():
@@ -6049,7 +6094,7 @@ async def preview_student_account_import(
6049
  email = str(row.get("email") or "").strip().lower()
6050
  grade = str(row.get("grade") or "").strip() or effective_default_grade
6051
  section = str(row.get("section") or "").strip() or effective_default_section
6052
- full_name = " ".join(part for part in [first_name, middle_name, last_name] if part).strip()
6053
 
6054
  issues: List[str] = []
6055
  if not first_name:
@@ -6255,12 +6300,8 @@ async def commit_student_account_import(
6255
  if str(row.get("status") or "") in {"valid", "move_confirmation_required"}
6256
  ]
6257
 
6258
- student_lrns = [
6259
- re.sub(r"\s+", "", str(row.get("studentId") or "")).lower()
6260
- for row in preview_rows
6261
- if str(row.get("studentId") or "").strip()
6262
- ]
6263
- duplicate_lrns = {lrn for lrn, count in Counter(student_lrns).items() if count > 1}
6264
  has_existing_lrn = any(
6265
  bool(row.get("duplicateLrnInFirestore"))
6266
  and str(row.get("status") or "") != "move_confirmation_required"
@@ -11974,6 +12015,10 @@ Remember:
11974
  detail=f"Quiz generation returned {len(parsed_questions)} of {request.numQuestions} requested questions. Please try again.",
11975
  )
11976
 
 
 
 
 
11977
  topic_provenance_map: Dict[str, Dict[str, Optional[str]]] = {}
11978
  for imported_topic in (imported_topics_payload.get("topics") or []):
11979
  title = str(imported_topic.get("title") or "").strip()
@@ -12077,11 +12122,9 @@ Remember:
12077
  @app.post("/api/quiz/preview", response_model=QuizResponse)
12078
  async def preview_quiz(http_request: Request, request: QuizGenerationRequest):
12079
  """
12080
- Generate a 3-question preview quiz for teachers to verify AI question
12081
  quality before assigning a full quiz to students.
12082
  """
12083
- # Override to produce only 3 questions
12084
- request.numQuestions = 3
12085
  return await generate_quiz(http_request, request)
12086
 
12087
 
 
350
  80,
351
  int(os.getenv("CHAT_STREAM_CONTINUATION_TAIL_CHARS", "900")),
352
  )
353
+ CHAT_RATE_LIMIT_REQUESTS = max(1, int(os.getenv("CHAT_RATE_LIMIT_REQUESTS", "60")))
354
+ CHAT_RATE_LIMIT_WINDOW_SECONDS = max(1, int(os.getenv("CHAT_RATE_LIMIT_WINDOW_SECONDS", "60")))
355
+ _chat_rate_limit_requests: Dict[str, List[float]] = defaultdict(list)
356
+ _chat_rate_limit_lock = Lock()
357
  CHAT_STREAM_COMPLETION_MODE_DEFAULT = os.getenv(
358
  "CHAT_STREAM_COMPLETION_MODE_DEFAULT",
359
  "auto",
 
2425
  # ─── AI Chat Tutor ─────────────────────────────────────────────
2426
 
2427
 
2428
+ def _enforce_chat_rate_limit(http_request: Optional[Request]) -> None:
2429
+ user = getattr(getattr(http_request, "state", None), "user", None)
2430
+ client = getattr(http_request, "client", None)
2431
+ user_key = getattr(user, "uid", None) or getattr(client, "host", None) or "anonymous"
2432
+ now = time.monotonic()
2433
+ cutoff = now - CHAT_RATE_LIMIT_WINDOW_SECONDS
2434
+ with _chat_rate_limit_lock:
2435
+ recent_requests = [
2436
+ timestamp for timestamp in _chat_rate_limit_requests[user_key]
2437
+ if timestamp > cutoff
2438
+ ]
2439
+ if len(recent_requests) >= CHAT_RATE_LIMIT_REQUESTS:
2440
+ _chat_rate_limit_requests[user_key] = recent_requests
2441
+ raise HTTPException(
2442
+ status_code=429,
2443
+ detail="You are asking too fast, please wait a moment",
2444
+ )
2445
+ recent_requests.append(now)
2446
+ _chat_rate_limit_requests[user_key] = recent_requests
2447
+
2448
+
2449
  MATH_TUTOR_SYSTEM_PROMPT = """You are Pulse, MathPulse AI's friendly math tutor for Filipino Senior High School
2450
  students. You help students understand and solve problems in General Mathematics,
2451
  Business Mathematics, Statistics & Probability, and Finite Mathematics, all aligned
 
2454
  YOUR BEHAVIOR RULES:
2455
  1. PERSONALIZE every response. Address the student by first name occasionally.
2456
  2. NEVER give direct answers to quiz or exam items — guide with hints and questions instead.
2457
+ On a student's first response to a math problem, do not give the final answer: withhold the final numeric answer and ask a guiding question first. Do not state a solved value such as "x = -3" in that first response. Reveal and explain the answer only after the student has attempted the next step or explicitly asks to check their work.
2458
  3. If the student is struggling on a critical gap topic, gently steer them back to
2459
  prerequisite concepts before moving forward.
2460
  4. Use the SDO Navotas step-by-step method for ALL solutions:
 
2735
 
2736
  @app.post("/api/chat", response_model=ChatResponse)
2737
  async def chat_tutor_endpoint(request: Request, chat_request: ChatRequest):
2738
+ return await chat_tutor(chat_request, request)
2739
 
2740
 
2741
+ async def chat_tutor(request: ChatRequest, http_request: Request = None):
2742
  """AI Math Tutor powered by Hugging Face Inference routing."""
2743
  _start_ms = int(time.monotonic() * 1000)
2744
  try:
2745
+ _enforce_chat_rate_limit(http_request)
2746
  # ─── Context-Aware Intent Gate (before scope check) ──────
2747
  # Load active state to determine if student is mid-session.
2748
  # If so, skip scope check for short/vague replies.
 
2939
 
2940
  @app.post("/api/chat/stream")
2941
  async def chat_tutor_stream_endpoint(request: Request, chat_request: ChatRequest):
2942
+ return await chat_tutor_stream(chat_request, request)
2943
 
2944
 
2945
+ async def chat_tutor_stream(request: ChatRequest, http_request: Request = None):
2946
  """SSE stream endpoint for AI Math Tutor chat responses."""
2947
+ _enforce_chat_rate_limit(http_request)
2948
  try:
2949
  # ─── Context-Aware Intent Gate (before scope check) ──────
2950
  _skip_scope_check = False
 
5361
  return candidate
5362
 
5363
 
5364
+ def _student_import_full_name(row: Dict[str, Any]) -> str:
5365
+ return " ".join(
5366
+ part for part in (str(row.get("firstName") or "").strip(), str(row.get("lastName") or "").strip()) if part
5367
+ )
5368
+
5369
+
5370
+ def _duplicate_student_lrn_rows(rows: Sequence[Dict[str, Any]]) -> Dict[str, List[int]]:
5371
+ row_numbers_by_lrn: Dict[str, List[int]] = {}
5372
+ for row in rows:
5373
+ # Normalize keys consistently with the import rejection gate: strip all
5374
+ # whitespace and lowercase so variants of the same LRN always collide.
5375
+ student_id = re.sub(r"\s+", "", str(row.get("studentId") or "")).lower()
5376
+ if student_id:
5377
+ row_numbers_by_lrn.setdefault(student_id, []).append(int(row.get("rowNumber") or 0))
5378
+ return {student_id: row_numbers for student_id, row_numbers in row_numbers_by_lrn.items() if len(row_numbers) > 1}
5379
+
5380
+
5381
  def _parse_provisioning_dataframe(df: Any) -> Dict[str, Any]:
5382
  header_map: Dict[str, str] = {}
5383
  for column in df.columns.tolist():
 
6094
  email = str(row.get("email") or "").strip().lower()
6095
  grade = str(row.get("grade") or "").strip() or effective_default_grade
6096
  section = str(row.get("section") or "").strip() or effective_default_section
6097
+ full_name = _student_import_full_name(row)
6098
 
6099
  issues: List[str] = []
6100
  if not first_name:
 
6300
  if str(row.get("status") or "") in {"valid", "move_confirmation_required"}
6301
  ]
6302
 
6303
+ duplicate_lrn_rows = _duplicate_student_lrn_rows(preview_rows)
6304
+ duplicate_lrns = set(duplicate_lrn_rows)
 
 
 
 
6305
  has_existing_lrn = any(
6306
  bool(row.get("duplicateLrnInFirestore"))
6307
  and str(row.get("status") or "") != "move_confirmation_required"
 
12015
  detail=f"Quiz generation returned {len(parsed_questions)} of {request.numQuestions} requested questions. Please try again.",
12016
  )
12017
 
12018
+ parsed_questions = parsed_questions[:request.numQuestions]
12019
+ for question_index, question in enumerate(parsed_questions):
12020
+ question["topic"] = effective_topics[question_index % len(effective_topics)]
12021
+
12022
  topic_provenance_map: Dict[str, Dict[str, Optional[str]]] = {}
12023
  for imported_topic in (imported_topics_payload.get("topics") or []):
12024
  title = str(imported_topic.get("title") or "").strip()
 
12122
  @app.post("/api/quiz/preview", response_model=QuizResponse)
12123
  async def preview_quiz(http_request: Request, request: QuizGenerationRequest):
12124
  """
12125
+ Generate a requested-count preview quiz for teachers to verify AI question
12126
  quality before assigning a full quiz to students.
12127
  """
 
 
12128
  return await generate_quiz(http_request, request)
12129
 
12130
 
tests/test_api.py CHANGED
@@ -855,6 +855,60 @@ class TestQuizGeneration:
855
  })
856
  assert response.status_code == 422
857
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
 
859
  class TestClassRecordImportMapping:
860
  def test_sanitize_column_mapping_drops_none_and_unknown_fields(self):
@@ -897,14 +951,16 @@ class TestClassRecordImportMapping:
897
  "points": 1,
898
  "explanation": "Slope = rise/run.",
899
  }
900
- for index in range(3)
901
  ])
902
  mock_chat.return_value = quiz_json
903
  response = client.post("/api/quiz/preview", json={
904
  "topics": ["Algebra"],
905
  "gradeLevel": "Grade 11",
 
906
  })
907
  assert response.status_code == 200
 
908
 
909
  @patch("main.call_hf_chat")
910
  def test_preview_quiz_rejects_partial_question_set(self, mock_chat):
@@ -926,6 +982,7 @@ class TestClassRecordImportMapping:
926
  response = client.post("/api/quiz/preview", json={
927
  "topics": ["Algebra"],
928
  "gradeLevel": "Grade 11",
 
929
  })
930
 
931
  assert response.status_code == 502
@@ -950,6 +1007,7 @@ class TestClassRecordImportMapping:
950
  response = client.post("/api/quiz/preview", json={
951
  "topics": ["Algebra"],
952
  "gradeLevel": "Grade 11",
 
953
  })
954
 
955
  assert response.status_code == 502
 
855
  })
856
  assert response.status_code == 422
857
 
858
+ @patch("main.call_hf_chat")
859
+ def test_generate_quiz_returns_requested_count_for_selected_topic(self, mock_chat):
860
+ questions = [
861
+ {
862
+ "questionType": "identification",
863
+ "question": f"Linear equation question {index + 1}",
864
+ "correctAnswer": "x = -3",
865
+ "topic": "Algebra",
866
+ }
867
+ for index in range(5)
868
+ ]
869
+ mock_chat.return_value = json.dumps(questions)
870
+
871
+ response = client.post("/api/quiz/preview", json={
872
+ "topics": ["Linear Equations"],
873
+ "gradeLevel": "Grade 11",
874
+ "numQuestions": 5,
875
+ })
876
+
877
+ assert response.status_code == 200
878
+ generated = response.json()["questions"]
879
+ assert len(generated) == 5
880
+ assert all(question["topic"] == "Linear Equations" for question in generated)
881
+ prompt = mock_chat.call_args.args[0]
882
+ assert "Linear Equations" in str(prompt)
883
+
884
+ @patch("main.call_hf_chat")
885
+ def test_chat_rate_limit_returns_429_after_flood(self, mock_chat, monkeypatch):
886
+ mock_chat.return_value = "What is the first step?"
887
+ monkeypatch.setattr(main_module, "CHAT_RATE_LIMIT_REQUESTS", 2)
888
+ monkeypatch.setattr(main_module, "CHAT_RATE_LIMIT_WINDOW_SECONDS", 60)
889
+ main_module._chat_rate_limit_requests.clear()
890
+
891
+ payload = {"message": "How do I solve x + 2 = 5?", "history": []}
892
+ assert client.post("/api/chat", json=payload).status_code == 200
893
+ assert client.post("/api/chat", json=payload).status_code == 200
894
+ limited = client.post("/api/chat", json=payload)
895
+
896
+ assert limited.status_code == 429
897
+ assert "You are asking too fast, please wait a moment" in limited.json()["detail"]
898
+
899
+
900
+ def test_ai_monitoring_summary_exposes_nested_telemetry_without_top_level_counters():
901
+ from routes import ai_monitoring
902
+
903
+ summary = ai_monitoring.get_monitoring_summary(_admin=object())
904
+
905
+ assert "telemetry" in summary
906
+ assert "dailyQuestions" not in summary
907
+ assert "averageLatencyMs" not in summary
908
+ assert "successRate" not in summary
909
+ assert "features" in summary
910
+ assert "pricingMeta" in summary
911
+
912
 
913
  class TestClassRecordImportMapping:
914
  def test_sanitize_column_mapping_drops_none_and_unknown_fields(self):
 
951
  "points": 1,
952
  "explanation": "Slope = rise/run.",
953
  }
954
+ for index in range(5)
955
  ])
956
  mock_chat.return_value = quiz_json
957
  response = client.post("/api/quiz/preview", json={
958
  "topics": ["Algebra"],
959
  "gradeLevel": "Grade 11",
960
+ "numQuestions": 5,
961
  })
962
  assert response.status_code == 200
963
+ assert len(response.json()["questions"]) == 5
964
 
965
  @patch("main.call_hf_chat")
966
  def test_preview_quiz_rejects_partial_question_set(self, mock_chat):
 
982
  response = client.post("/api/quiz/preview", json={
983
  "topics": ["Algebra"],
984
  "gradeLevel": "Grade 11",
985
+ "numQuestions": 3,
986
  })
987
 
988
  assert response.status_code == 502
 
1007
  response = client.post("/api/quiz/preview", json={
1008
  "topics": ["Algebra"],
1009
  "gradeLevel": "Grade 11",
1010
+ "numQuestions": 3,
1011
  })
1012
 
1013
  assert response.status_code == 502
tests/test_chat_jev_socratic.py CHANGED
@@ -74,6 +74,29 @@ async def test_conceptual_question_uses_normal_chat_generation(
74
  assert "Given → Formula → Substitute → Compute → Conclude" in system_prompt
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  @pytest.mark.asyncio
78
  async def test_jev_failure_fails_open_to_normal_chat_generation(
79
  monkeypatch: pytest.MonkeyPatch,
 
74
  assert "Given → Formula → Substitute → Compute → Conclude" in system_prompt
75
 
76
 
77
+ @pytest.mark.asyncio
78
+ async def test_chat_system_prompt_requires_guidance_before_final_numeric_answer(
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ ) -> None:
81
+ captured_messages: list[dict[str, str]] = []
82
+
83
+ async def route_intent(_message: str) -> dict[str, object]:
84
+ return {"choice": "conceptual_confusion", "confidence": 0.91}
85
+
86
+ async def capture_llm(messages: list[dict[str, str]], **_kwargs: object) -> str:
87
+ captured_messages.extend(messages)
88
+ return "What operation would isolate x first?"
89
+
90
+ monkeypatch.setattr(main, "route_student_intent", route_intent)
91
+ monkeypatch.setattr(main, "call_hf_chat_async", capture_llm)
92
+
93
+ await main.chat_tutor(main.ChatRequest(message="What is the answer to 3x+9=0?"))
94
+
95
+ system_prompt = captured_messages[0]["content"].lower()
96
+ assert "do not give the final answer" in system_prompt
97
+ assert "ask a guiding question first" in system_prompt
98
+
99
+
100
  @pytest.mark.asyncio
101
  async def test_jev_failure_fails_open_to_normal_chat_generation(
102
  monkeypatch: pytest.MonkeyPatch,
tests/test_student_account_import.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+
3
+ import main
4
+
5
+
6
+ def test_provisioning_uses_first_and_last_name_not_middle_initial():
7
+ parsed = main._parse_provisioning_dataframe(
8
+ pd.DataFrame(
9
+ [{"First Name": "Avery", "Middle Initial": "B", "Last Name": "Santos", "LRN": "123456789012"}]
10
+ )
11
+ )
12
+
13
+ row = parsed["rows"][0]
14
+ assert main._student_import_full_name(row) == "Avery Santos"
15
+
16
+
17
+ def test_duplicate_lrns_report_source_rows_and_reject_entire_batch():
18
+ parsed = main._parse_provisioning_dataframe(
19
+ pd.DataFrame(
20
+ [
21
+ {"First Name": "Avery", "Last Name": "Santos", "LRN": "123456789012"},
22
+ {"First Name": "Blair", "Last Name": "Reyes", "LRN": "123456789012"},
23
+ {"First Name": "Casey", "Last Name": "Lim", "LRN": "210987654321"},
24
+ ]
25
+ )
26
+ )
27
+
28
+ assert main._duplicate_student_lrn_rows(parsed["rows"]) == {
29
+ "123456789012": [2, 3]
30
+ }