File size: 4,155 Bytes
4b42511
 
 
d66d64c
4b42511
 
 
 
 
 
 
 
 
 
 
 
d66d64c
4b42511
 
 
d66d64c
4b42511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86226ed
 
 
 
 
4b42511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86226ed
d66d64c
4b42511
 
 
 
 
 
 
 
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
"""A slow RAG lesson must not block the server's event loop.

rag_lesson used to call the synchronous retrieval and inference client directly inside an async
handler, so a 15-18 s Claude call froze every other request (health checks, learning paths,
lists) until it returned.
"""
from __future__ import annotations

import asyncio
import json
import os
import time
from unittest.mock import patch

import httpx

os.environ["ANTHROPIC_API_KEY"] = "mock-key-for-testing"

import main as main_module
from main import app
from services.ai_client import REASONER_MODEL

BLOCKING_SECONDS = 1.0
CHUNKS = [
    {
        "content": "A rational function is a quotient of two polynomial functions.",
        "source_file": "SHS_GM_Q1.pdf",
        "score": 0.95,
        "content_domain": "general",
    }
]
PAYLOAD = {
    "topic": "Rational Functions",
    "subject": "General Mathematics",
    "quarter": 1,
    "lessonTitle": "Introduction to Rational Functions",
}
LESSON_JSON = json.dumps({
    "sections": [
        {"type": "introduction", "content": "Intro."},
        {"type": "key_concepts", "content": "Concepts."},
        {"type": "video", "content": "Video."},
        {"type": "worked_examples", "examples": [{"problem": "Example."}]},
        {"type": "important_notes", "bulletPoints": ["Note."]},
        {"type": "try_it_yourself", "practiceProblems": [{"question": "Practice."}]},
        {"type": "summary", "content": "Summary."},
    ]
})


def _blocking_retrieval(*_args: object, **_kwargs: object) -> tuple[list[dict[str, object]], str]:
    time.sleep(BLOCKING_SECONDS)
    return CHUNKS, "exact_file"


class _BlockingInferenceClient:
    def generate_from_messages(self, _request: object) -> str:
        time.sleep(BLOCKING_SECONDS)
        return LESSON_JSON


def _blocking_reasoner_stream(*_args: object, **_kwargs: object) -> str:
    time.sleep(BLOCKING_SECONDS)
    return LESSON_JSON


async def _verified(reference_text: str, generated_text: str) -> dict[str, object]:
    return {"verified": True, "pCorrect": 0.95}


async def _lesson_while_polling_health() -> tuple[int, float, float]:
    """Poll /health for as long as the lesson runs; return the longest gap between answered polls."""
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test", headers={"Authorization": "Bearer test-token"}) as client:
        started = time.perf_counter()
        lesson_task = asyncio.create_task(client.post("/api/rag/lesson", json=PAYLOAD))
        answered_at = [time.perf_counter()]
        while not lesson_task.done():
            health = await client.get("/health")
            assert health.status_code == 200
            answered_at.append(time.perf_counter())
            await asyncio.sleep(0.05)
        lesson = await lesson_task
        lesson_seconds = time.perf_counter() - started
        longest_gap = max(later - earlier for earlier, later in zip(answered_at, answered_at[1:]))
        return lesson.status_code, longest_gap, lesson_seconds


def test_health_answers_while_a_rag_lesson_is_blocked_in_retrieval_and_inference() -> None:
    with patch.object(
        main_module.firebase_auth,
        "verify_id_token",
        return_value={"uid": "student_S", "role": "student"},
    ), patch("routes.rag_routes.retrieve_lesson_pdf_context", _blocking_retrieval), patch(
        "routes.rag_routes._get_inference_client", return_value=_BlockingInferenceClient()
    ), patch("routes.rag_routes._stream_reasoner_lesson", _blocking_reasoner_stream), patch(
        "routes.rag_routes._lesson_primary_model", return_value=REASONER_MODEL
    ), patch("routes.rag_routes.verify_lesson_factuality", _verified), patch(
        "routes.rag_routes._fetch_youtube_videos", return_value=[]
    ), patch("routes.rag_routes._log_rag_usage", return_value=None):
        lesson_status, longest_gap, lesson_seconds = asyncio.run(_lesson_while_polling_health())

    assert lesson_status == 200
    assert lesson_seconds >= 2 * BLOCKING_SECONDS
    assert longest_gap < BLOCKING_SECONDS / 2, f"/health went unanswered for {longest_gap:.2f}s behind a blocked lesson"