Spaces:
Sleeping
Sleeping
File size: 64,765 Bytes
a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 191750c a2b9ad1 | 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 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 | """Offline tests for the Creighton Anatomy Learning Platform (Gemini, Supabase, and the web are stubbed).
Run from the app folder, with the lecture PDFs available:
LECTURES_DIR=/path/to/Lectures GEMINI_API_KEY=test python3 -m unittest -v test_app
"""
from __future__ import annotations
import io
import json
import os
import unittest
import urllib.error
import urllib.request
from types import SimpleNamespace
from unittest import mock
os.environ.setdefault("GEMINI_API_KEY", "test")
os.environ.pop("SUPABASE_URL", None)
os.environ.pop("SUPABASE_KEY", None)
import app # noqa: E402 (loads the lecture PDFs)
import supabase_store # noqa: E402
import web_sources # noqa: E402
from app import Outcome, SlideRef, TutorReply, WebReply # noqa: E402
class FakeStore:
"""Records every call the app makes; accounts and messages live in memory."""
def __init__(self, enabled: bool = True) -> None:
self.enabled = enabled
self.users: dict[str, dict] = {}
self.messages: list[dict] = []
self.calls: list[tuple] = []
def config_hint(self):
return "" if self.enabled else "Missing Space secret(s): SUPABASE_URL"
def get_user(self, user_id):
return self.users.get(user_id), None
def create_user(self, user_id, email):
if user_id in self.users:
return None, supabase_store.EXISTS
self.users[user_id] = {"user_id": user_id, "email": email}
return self.users[user_id], None
def load_messages(self, user_id, limit=200):
return [m for m in self.messages if m["user_id"] == user_id][-limit:], None
def enqueue(self, method, *args):
self.calls.append((method,) + args)
if method == "append_messages":
user_id, session_id, rows = args
self.messages += [dict(row, user_id=user_id) for row in rows]
def events(self):
return [call[3] for call in self.calls if call[0] == "log_event"]
def flush(self, timeout=3.0):
self.calls.append(("flush",))
return True
def first_text_slide_with_figure() -> tuple[app.Slide, app.Slide]:
"""A slide with text followed by a diagram or image-only slide (exists in every lecture set)."""
for lecture in app.LECTURES.values():
for slide, following in zip(lecture.slides, lecture.slides[1:]):
if not slide.diagram and not slide.image_only and not slide.section and (following.diagram or following.image_only):
return slide, following
raise AssertionError("no text slide followed by a figure slide")
def signed_in_session(store: FakeStore, user_id: str = "jdoe") -> app.Session:
session = app.Session()
session.user_id, session.email, session.session_id = user_id, f"{user_id}@example.com", "sess1"
return session
def drain(generator) -> list[tuple]:
return list(generator)
class CitationTests(unittest.TestCase):
def test_cited_slides_expands_ranges_and_drops_missing(self):
lecture = next(iter(app.LECTURES.values()))
answer = f"Text (Lecture {lecture.number}, slides 1-3 and 5). More (Lecture 999, slide 1)."
self.assertEqual(app._cited_slides(answer), [(lecture.number, 1), (lecture.number, 2), (lecture.number, 3), (lecture.number, 5)])
def test_relevant_slides_keeps_cited_slides_and_their_figures(self):
slide, figure = first_text_slide_with_figure()
other = app.LECTURES[slide.lecture].slides[-1]
answer = f"Fact (Lecture {slide.lecture}, slide {slide.number})."
refs = [SlideRef(lecture=slide.lecture, slide=slide.number), SlideRef(lecture=figure.lecture, slide=figure.number),
SlideRef(lecture=other.lecture, slide=other.number)]
kept = app._relevant_slides(refs, answer)
self.assertEqual([(r.lecture, r.slide) for r in kept], [slide.key, figure.key] if other.key != figure.key else [slide.key, figure.key])
def test_relevant_slides_is_empty_without_citations(self):
slide, _ = first_text_slide_with_figure()
self.assertEqual(app._relevant_slides([SlideRef(lecture=slide.lecture, slide=slide.number)], "No citations here."), [])
def test_slides_note_groups_by_lecture(self):
first, second = list(app.LECTURES.values())[:2]
answer = f"(Lecture {first.number}, slide 2) and (Lecture {first.number}, slide 1) and (Lecture {second.number}, slide 1)"
note = app._slides_note(answer)
self.assertIn(f"Lecture {first.number} ({first.title}), slides 1, 2", note)
self.assertIn(f"Lecture {second.number} ({second.title}), slide 1", note)
self.assertEqual(app._slides_note("nothing cited"), "Course lecture slides.")
def test_source_note_round_trip(self):
content = app._with_source("Answer text", "Course lecture slides.")
self.assertEqual(app._split_source(content), ("Answer text", "Course lecture slides."))
self.assertEqual(app._split_source("plain"), ("plain", ""))
def test_none_note_variants(self):
page = web_sources.Page("Kenhub", "https://www.kenhub.com/en/library/anatomy/retina", "Retina")
self.assertIn("(Kenhub: [Retina](https://www.kenhub.com/en/library/anatomy/retina)) do not answer it either", app._none_note([page]))
self.assertIn("no matching article was found", app._none_note([]))
self.assertTrue(app._none_note([], "Kenhub could not be reached").endswith("(Kenhub could not be reached.)"))
class TurnTests(unittest.TestCase):
def setUp(self):
self.store = FakeStore()
self.patches = [mock.patch.object(app, "STORE", self.store)]
for patch in self.patches:
patch.start()
self.slide, self.figure = first_text_slide_with_figure()
def tearDown(self):
for patch in self.patches:
patch.stop()
def slides_reply(self, covered=True, extra_slide=None):
answer = f"## Answer\nA fact (Lecture {self.slide.lecture}, slide {self.slide.number})."
refs = [SlideRef(lecture=self.slide.lecture, slide=self.slide.number), SlideRef(lecture=self.figure.lecture, slide=self.figure.number)]
if extra_slide is not None:
refs.append(SlideRef(lecture=extra_slide.lecture, slide=extra_slide.number))
return TutorReply(answer=answer if covered else "The slides do not cover this.", slides=refs, covered_by_slides=covered, search_topics=["retina"])
def test_thinking_message_then_answer_from_slides(self):
other = app.LECTURES[self.slide.lecture].slides[-1]
session = signed_in_session(self.store)
with mock.patch.object(app, "gemini_reply", return_value=self.slides_reply(extra_slide=other)):
outputs = drain(app.run_turn("ask", "What is it?", session))
self.assertGreaterEqual(len(outputs), 2)
pending = outputs[0][0][-1]
self.assertEqual(pending["metadata"], {"title": "Thinking", "status": "pending"})
self.assertEqual(outputs[0][0][-2], {"role": "user", "content": "What is it?"})
self.assertFalse(outputs[0][-3]["interactive"]) # Ask disabled while thinking
self.assertEqual(len(outputs[0]), len(app.turn_outputs))
final = outputs[-1][0][-1]
self.assertNotIn("metadata", final)
self.assertTrue(final["content"].endswith(f"slide {self.slide.number}."))
self.assertIn("**Source:** Course lecture slides:", final["content"])
self.assertEqual(outputs[-1][1], "") # question box cleared
self.assertEqual(outputs[-1][-1], session.version) # the page learns the chat version it now shows
self.assertEqual(session.state["slides"], [[self.slide.lecture, self.slide.number], [self.figure.lecture, self.figure.number]])
self.assertEqual(session.state["topic"], "What is it?")
self.assertFalse(session.busy)
saved = [c for c in self.store.calls if c[0] == "append_messages"]
self.assertEqual(len(saved), 1)
rows = saved[0][3]
self.assertEqual([r["role"] for r in rows], ["user", "assistant"])
self.assertEqual(rows[1]["source"], "slides")
self.assertEqual(self.store.events(), ["ask"])
def test_falls_back_to_reference_pages(self):
session = signed_in_session(self.store)
page = web_sources.Page("TeachMeAnatomy", "https://teachmeanatomy.info/head/organs/eye/retina/", "The Retina", "text")
web = WebReply(answer="Ten layers (TeachMeAnatomy: The Retina).", covered=True, used_urls=[page.url])
with mock.patch.object(app, "gemini_reply", return_value=self.slides_reply(covered=False)), \
mock.patch.object(web_sources, "find_pages", return_value=([page], "")) as find, \
mock.patch.object(app, "gemini_web_reply", return_value=web) as web_call:
outputs = drain(app.run_turn("ask", "What are the layers of the retina?", session))
find.assert_called_once_with(["retina"])
self.assertEqual(web_call.call_args[0][0], "Student question: What are the layers of the retina?")
self.assertEqual(web_call.call_args[0][2], [page])
progress = [o[0][-1]["content"] for o in outputs[:-1]]
self.assertEqual(progress, ["Searching the lecture slides...",
"The lecture slides do not cover this request. Checking TeachMeAnatomy and Kenhub..."])
final = outputs[-1][0][-1]["content"]
self.assertTrue(final.startswith("Ten layers (TeachMeAnatomy: The Retina)."))
self.assertIn("Answered from TeachMeAnatomy: [The Retina](https://teachmeanatomy.info/head/organs/eye/retina/).", final)
self.assertEqual(session.state["slides"], [])
self.assertIsNone(session.state["view"])
self.assertEqual(session.state["history"][-1]["text"], web.answer)
rows = [c for c in self.store.calls if c[0] == "append_messages"][0][3]
self.assertEqual(rows[1]["source"], "web")
def test_reports_when_no_reference_page_matches(self):
session = signed_in_session(self.store)
with mock.patch.object(app, "gemini_reply", return_value=self.slides_reply(covered=False)), \
mock.patch.object(web_sources, "find_pages", return_value=([], "Kenhub could not be reached")), \
mock.patch.object(app, "gemini_web_reply") as web_call:
outputs = drain(app.run_turn("ask", "Unknown thing?", session))
web_call.assert_not_called()
final = outputs[-1][0][-1]["content"]
self.assertIn("The slides do not cover this.", final)
self.assertIn("no matching article was found on TeachMeAnatomy or Kenhub. (Kenhub could not be reached.)", final)
rows = [c for c in self.store.calls if c[0] == "append_messages"][0][3]
self.assertEqual(rows[1]["source"], "none")
def test_reports_when_reference_pages_do_not_answer(self):
session = signed_in_session(self.store)
page = web_sources.Page("Kenhub", "https://www.kenhub.com/en/library/anatomy/retina", "Retina", "text")
with mock.patch.object(app, "gemini_reply", return_value=self.slides_reply(covered=False)), \
mock.patch.object(web_sources, "find_pages", return_value=([page], "")), \
mock.patch.object(app, "gemini_web_reply", return_value=WebReply(answer="The pages do not answer this.", covered=False)):
outputs = drain(app.run_turn("ask", "Unknown thing?", session))
final = outputs[-1][0][-1]["content"]
self.assertTrue(final.startswith("The pages do not answer this."))
self.assertIn("(Kenhub: [Retina](https://www.kenhub.com/en/library/anatomy/retina)) do not answer it either", final)
def test_relevant_slides_falls_back_to_cited_slides(self):
other = app.LECTURES[self.slide.lecture].slides[-1]
answer = f"Fact (Lecture {self.slide.lecture}, slide {self.slide.number})."
kept = app._relevant_slides([SlideRef(lecture=other.lecture, slide=other.number)], answer)
self.assertEqual([(r.lecture, r.slide) for r in kept], [self.slide.key])
def test_citation_groups_with_repeated_slide_word(self):
lecture = self.slide.lecture
self.assertEqual(app._cited_slides(f"(Lecture {lecture}, slide 1, slide 2)"), [(lecture, 1), (lecture, 2)])
self.assertEqual(app._verify_citations(f"(Lecture {lecture}, slide 1, slide 999)"),
f"(Lecture {lecture}, slide 1, slide 999{app.NOT_FOUND_TAG})")
def clinical(self, slide_reply, pages, web_reply):
session = signed_in_session(self.store)
session.state["topic"] = "knee and ankle"
with mock.patch.object(app, "gemini_reply", return_value=slide_reply) as slide_call, \
mock.patch.object(web_sources, "find_pages", return_value=(pages, "")) as find, \
mock.patch.object(app, "gemini_web_reply", return_value=web_reply) as web_call:
outputs = drain(app.run_turn("clinical", "", session))
rows = [c for c in self.store.calls if c[0] == "append_messages"][0][3]
return session, outputs, rows, slide_call, find, web_call
def test_clinical_answer_names_each_source_and_never_the_tutor(self):
page = web_sources.Page("TeachMeAnatomy", "https://teachmeanatomy.info/lower-limb/joints/knee-joint/", "The Knee Joint", "text")
slide_reply = TutorReply(answer=f"- An unhappy triad point (Lecture {self.slide.lecture}, slide {self.slide.number}).",
slides=[], covered_by_slides=True, search_topics=["knee joint", "ankle joint"])
web_reply = WebReply(answer="- Bursitis (TeachMeAnatomy: The Knee Joint).", covered=True, used_urls=[page.url.rstrip("/")])
session, outputs, rows, slide_call, find, web_call = self.clinical(slide_reply, [page], web_reply)
find.assert_called_once_with(["knee joint", "ankle joint"])
self.assertIn("List the clinical correlations for this topic that the slides state", slide_call.call_args[0][0])
self.assertIn("that the reference pages state: knee and ankle", web_call.call_args[0][0])
progress = [o[0][-1]["content"] for o in outputs[:-1]]
self.assertEqual(progress, ["Searching the lecture slides for clinical notes...",
"Checking TeachMeAnatomy and Kenhub for clinical notes..."])
body, note = app._split_source(outputs[-1][0][-1]["content"])
self.assertEqual(body, f"### From the lecture slides\n\n- An unhappy triad point (Lecture {self.slide.lecture}, slide {self.slide.number}).\n\n"
"### From TeachMeAnatomy\n\n- Bursitis (TeachMeAnatomy: The Knee Joint).")
self.assertTrue(note.startswith(f"Course lecture slides: Lecture {self.slide.lecture} ("))
self.assertTrue(note.endswith("; TeachMeAnatomy: [The Knee Joint](https://teachmeanatomy.info/lower-limb/joints/knee-joint/)."))
for phrase in ("beyond", "tutor", "standard clinical anatomy"):
self.assertNotIn(phrase, outputs[-1][0][-1]["content"].lower())
self.assertEqual(rows[1]["source"], "slides+web")
self.assertEqual(session.state["last_source"], "slides+web")
def test_clinical_from_the_sites_when_the_slides_have_none(self):
page = web_sources.Page("Kenhub", "https://www.kenhub.com/en/library/anatomy/the-ankle-joint", "Ankle joint", "text")
slide_reply = TutorReply(answer="The lecture slides do not include clinical notes on this topic.", slides=[],
covered_by_slides=False, search_topics=["ankle joint"])
web_reply = WebReply(answer="### Sprains\n- Inversion sprain (Kenhub: Ankle joint).", covered=True, used_urls=[page.url])
session, outputs, rows, *_ = self.clinical(slide_reply, [page], web_reply)
body, note = app._split_source(outputs[-1][0][-1]["content"])
self.assertEqual(body, "### From the lecture slides\n\nThe lecture slides do not include clinical notes on this topic.\n\n"
"### From Kenhub\n\n#### Sprains\n- Inversion sprain (Kenhub: Ankle joint).")
self.assertEqual(note, "The lecture slides do not include clinical notes on this topic. Answered from "
"Kenhub: [Ankle joint](https://www.kenhub.com/en/library/anatomy/the-ankle-joint).")
self.assertEqual(rows[1]["source"], "web")
def test_clinical_with_nothing_found_says_so(self):
slide_reply = TutorReply(answer="The lecture slides do not include clinical notes on this topic.", slides=[],
covered_by_slides=False, search_topics=["retina"])
session, outputs, rows, _, _, web_call = self.clinical(slide_reply, [], None)
web_call.assert_not_called()
body, note = app._split_source(outputs[-1][0][-1]["content"])
self.assertEqual(body, "Neither the lecture slides nor the TeachMeAnatomy and Kenhub pages checked include clinical notes on this topic.")
self.assertEqual(rows[1]["source"], "none")
def test_clinical_reads_the_pages_while_searching_the_slides_when_the_topic_is_known(self):
session = signed_in_session(self.store)
session.state.update(topic="knee", search_topics=["knee joint"])
page = web_sources.Page("Kenhub", "https://www.kenhub.com/en/library/anatomy/the-knee-joint", "Knee joint", "text")
slide_reply = TutorReply(answer=f"- A point (Lecture {self.slide.lecture}, slide {self.slide.number}).", slides=[],
covered_by_slides=True, search_topics=["something else"])
with mock.patch.object(app, "gemini_reply", return_value=slide_reply), \
mock.patch.object(web_sources, "find_pages", return_value=([page], "")) as find, \
mock.patch.object(app, "gemini_web_reply", side_effect=app.TutorError("busy")):
outputs = drain(app.run_turn("clinical", "", session))
find.assert_called_once_with(["knee joint"]) # the structures of the answer that set the topic
self.assertEqual([o[0][-1]["content"] for o in outputs[:-1]],
["Searching the lecture slides, TeachMeAnatomy, and Kenhub for clinical notes..."])
body, note = app._split_source(outputs[-1][0][-1]["content"]) # the page step failed: the slide part still shows
self.assertIn("### From the lecture slides\n\n- A point", body)
self.assertIn("The TeachMeAnatomy and Kenhub pages could not be read right now.", body)
self.assertIn("could not be read right now", note)
self.assertNotIn("add no clinical notes", note)
self.assertEqual(session.state["search_topics"], ["knee joint"])
def test_ask_remembers_the_structures_for_clinical_correlation(self):
session = signed_in_session(self.store)
reply = self.slides_reply()
reply.search_topics = ["knee joint", "ankle joint"]
with mock.patch.object(app, "gemini_reply", return_value=reply):
drain(app.run_turn("ask", "Knee and ankle?", session))
self.assertEqual(session.state["search_topics"], ["knee joint", "ankle joint"])
def test_beyond_guard_catches_heading_variants(self):
for heading in ["### **Beyond the slides**", "### 2. Beyond the slides", "Beyond the slides:", "*Beyond the slides*",
"### Additional clinical points (beyond the slides)", "**Beyond the lecture slides**"]:
text = f"### Ligaments\n- Kept (Lecture 1, slide 2).\n{heading}\n- Tutor point\n#### Detail\n- More tutor\n### Summary\nKept."
self.assertEqual(app._drop_beyond_sections(text), "### Ligaments\n- Kept (Lecture 1, slide 2).\n### Summary\nKept.", heading)
self.assertEqual(app._drop_beyond_sections("**Beyond doubt, the ACL limits translation** (Lecture 1, slide 2)."),
"**Beyond doubt, the ACL limits translation** (Lecture 1, slide 2).")
def test_simplify_never_brings_back_old_beyond_sections_or_the_tutor_note(self):
session = signed_in_session(self.store)
old = "### From the reference pages\n- Point.\n\n### Beyond the reference pages\n- Tutor point."
session.state.update(topic="knee", last_answer=old, last_source="web", last_note="Answered from Kenhub: [Knee](u).")
rewritten = TutorReply(answer="### From the reference pages\n- Simple point.\n### Beyond the reference pages\n- Tutor.",
slides=[], covered_by_slides=False)
with mock.patch.object(app, "_generate", return_value=rewritten.model_dump_json()) as generate:
outputs = drain(app.run_turn("simplify", "", session))
sent = generate.call_args[0][0][-1].parts[0].text # the request sent to Gemini
self.assertIn("Previous answer:", sent)
self.assertNotIn("Beyond", sent) # the old section is not sent ...
self.assertNotIn("Beyond", outputs[-1][0][-1]["content"]) # ... nor shown if Gemini adds one
_, state = app._restore([{"role": "assistant", "mode": "clinical", "topic": "t", "source": "slides",
"content": "A" + app.SOURCE_MARK + "Course lecture slides." + app.OLD_TUTOR_NOTE}])
self.assertEqual(state["last_note"], "Course lecture slides.")
def test_simplify_of_a_clinical_answer_from_both_sources_checks_slide_citations(self):
session = signed_in_session(self.store)
session.state.update(topic="knee", last_answer="prev", last_source="slides+web", last_note="note")
rewritten = TutorReply(answer="Point (Lecture 1, slide 999) and (Lecture 1, slide 2).", slides=[], covered_by_slides=False)
with mock.patch.object(app, "gemini_rewrite", return_value=rewritten):
drain(app.run_turn("simplify", "", session))
self.assertIn(app.NOT_FOUND_TAG, session.messages[-1]["content"])
self.assertEqual(session.state["slides"], [[1, 2]])
def test_clinical_says_when_no_article_exists(self):
slide_reply = TutorReply(answer=f"- A point (Lecture {self.slide.lecture}, slide {self.slide.number}).", slides=[],
covered_by_slides=True, search_topics=["superior thoracic aperture"])
session, outputs, rows, *_ = self.clinical(slide_reply, [], None)
body, note = app._split_source(outputs[-1][0][-1]["content"])
self.assertIn("No TeachMeAnatomy or Kenhub article about this topic was found.", body)
self.assertTrue(note.endswith("No TeachMeAnatomy or Kenhub article about this topic was found."))
def test_follow_ups_and_quick_actions_search_the_slides_of_the_known_structures(self):
state = {"search_topics": ["knee joint"]}
self.assertEqual(app.slide_query("ask", "How is it injured?", state), "How is it injured? knee joint")
self.assertEqual(app.slide_query("ask", "What muscles form the rotator cuff?", state), "What muscles form the rotator cuff?")
self.assertEqual(app.slide_query("quiz", "How is it injured?", state), "How is it injured? knee joint")
self.assertEqual(app.slide_query("ask", "How is it injured?", {}), "How is it injured?")
upper_limb = {n for n, lec in app.LECTURES.items() if any(w in lec.title.lower() for w in ("shoulder", "brachial", "arm"))}
lectures = {s.lecture for s in app.retrieve_slides(app.slide_query("ask", "How is it injured?", state))}
self.assertFalse(lectures & upper_limb, lectures)
def test_beyond_sections_are_removed(self):
text = ("### From the reference pages\n- Point (TeachMeAnatomy: The Knee Joint).\n\n"
"### Beyond the reference pages\nThe following points are standard clinical anatomy.\n- Own knowledge\n"
"#### Sub point\n- more\n### Summary\nKept.\n\n**Beyond the slides**\n- tutor point\n### Next\nKept too.")
self.assertEqual(app._drop_beyond_sections(text),
"### From the reference pages\n- Point (TeachMeAnatomy: The Knee Joint).\n\n### Summary\nKept.\n\n### Next\nKept too.")
self.assertEqual(app._drop_beyond_sections("The rotator cuff lies beyond the glenoid."), "The rotator cuff lies beyond the glenoid.")
def test_prompts_never_ask_for_outside_knowledge(self):
for text in [app.TASK_PROMPTS["clinical"], app.WEB_TASK_PROMPTS["clinical"], app.WEB_SYSTEM_PROMPT, app.TASK_PROMPTS["simplify"]]:
self.assertNotIn("Beyond the slides'", text)
self.assertNotIn("standard clinical anatomy", text)
self.assertNotIn('write "the reference pages" instead of "the slides"', app.WEB_SYSTEM_PROMPT)
self.assertIn("Never answer from outside knowledge", app.SYSTEM_PROMPT)
def test_ignored_clicks_are_still_counted(self):
session = signed_in_session(self.store)
with mock.patch.object(app, "gemini_reply") as call:
drain(app.run_turn("ask", " ", session))
call.assert_not_called()
self.assertEqual(self.store.events(), ["ask"])
self.assertEqual(session.messages, [])
def test_show_image_keeps_only_cited_slides(self):
session = signed_in_session(self.store)
session.state["topic"] = "topic"
other = app.LECTURES[self.slide.lecture].slides[-1]
reply = TutorReply(answer=f"Slide (Lecture {self.slide.lecture}, slide {self.slide.number}) shows it.",
slides=[SlideRef(lecture=other.lecture, slide=other.number), SlideRef(lecture=self.slide.lecture, slide=self.slide.number),
SlideRef(lecture=self.figure.lecture, slide=self.figure.number)],
covered_by_slides=True)
with mock.patch.object(app, "gemini_reply", return_value=reply):
self._show(session)
self.assertEqual(session.state["slides"], [[self.slide.lecture, self.slide.number], [self.figure.lecture, self.figure.number]])
def test_simplify_and_show_do_not_fall_back(self):
session = signed_in_session(self.store)
session.state.update(topic="retina", last_answer="prev", last_source="web",
last_note="The lecture slides do not cover this request. Answered from Kenhub: [Retina](u).")
with mock.patch.object(app, "gemini_rewrite", return_value=TutorReply(answer="Simpler.", slides=[], covered_by_slides=False)) as rewrite, \
mock.patch.object(app, "gemini_reply") as slides_call, \
mock.patch.object(web_sources, "find_pages") as find:
outputs = drain(app.run_turn("simplify", "", session))
find.assert_not_called()
slides_call.assert_not_called() # a web answer is rewritten without the slide prompt
self.assertIn("Previous answer:\nprev", rewrite.call_args[0][0])
self.assertEqual(outputs[0][0][-1]["content"], "Rewriting the previous answer...")
self.assertTrue(outputs[-1][0][-1]["content"].endswith("Answered from Kenhub: [Retina](u)."))
rows = [c for c in self.store.calls if c[0] == "append_messages"][0][3]
self.assertEqual(rows[1]["source"], "web")
def test_quiz_answer_key_is_collapsed_before_the_source_note(self):
session = signed_in_session(self.store)
session.state["topic"] = "brachial plexus"
reply = TutorReply(answer=f"1. Q?\n\n### Answer key\n1. A (Lecture {self.slide.lecture}, slide {self.slide.number})", slides=[], covered_by_slides=True)
with mock.patch.object(app, "gemini_reply", return_value=reply):
outputs = drain(app.run_turn("quiz", "", session))
content = outputs[-1][0][-1]["content"]
self.assertLess(content.index("</details>"), content.index("**Source:**"))
self.assertEqual(session.state["last_answer"], "") # quizzes never become the answer to simplify
def test_error_is_shown_without_source_note(self):
session = signed_in_session(self.store)
with mock.patch.object(app, "gemini_reply", side_effect=app.TutorError("The tutor is busy right now.")):
outputs = drain(app.run_turn("ask", "Q?", session))
final = outputs[-1][0][-1]
self.assertEqual(final, {"role": "assistant", "content": "The tutor is busy right now."})
self.assertEqual(session.state["history"], [])
self.assertFalse(session.busy)
rows = [c for c in self.store.calls if c[0] == "append_messages"][0][3]
self.assertEqual(rows[1]["source"], "error")
def test_turns_need_a_signed_in_user(self):
session = app.Session()
with mock.patch.object(app, "gemini_reply") as call, mock.patch.object(app.gr, "Warning") as warning:
outputs = drain(app.run_turn("ask", "Q?", session))
call.assert_not_called()
self.assertEqual(outputs, [app.NOOP_TURN]) # the visible chat is left as it is
warning.assert_called_once_with(app.SIGNED_OUT_TEXT)
self.assertEqual(session.messages, [])
with mock.patch.object(app, "get_session", return_value=session), mock.patch.object(app.gr, "Warning"):
shown = list(app.on_show_image("", "", 0, SimpleNamespace(session_hash="x")))
self.assertEqual(shown, [app.NOOP_SHOW])
def test_turn_signs_the_student_back_in_after_a_restart(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
self.store.messages = [{"user_id": "jdoe", "role": "user", "content": "Q1", "mode": "ask", "topic": "t1", "source": None},
{"user_id": "jdoe", "role": "assistant", "content": "A1", "mode": "ask", "topic": "t1", "source": "slides"}]
session = app.Session() # what a restarted server knows about the tab
with mock.patch.object(app, "gemini_reply", return_value=self.slides_reply()):
outputs = drain(app.run_turn("ask", "Q2?", session, ("jdoe", "JDoe@example.com", 1791000000000)))
self.assertEqual(session.user_id, "jdoe")
self.assertEqual([m["content"] for m in outputs[-1][0]][:3], ["Q1", "A1", "Q2?"])
self.assertIn("resume", self.store.events())
self.assertEqual([c[0] for c in self.store.calls][0], "start_session") # no other tab: no waiting
def test_no_automatic_sign_in_from_the_sign_in_form_or_a_wrong_email(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
for creds in [("jdoe", "jdoe@example.com", 0), ("jdoe", "other@example.com", 5), ("nobody", "jdoe@example.com", 5)]:
session = app.Session()
with mock.patch.object(app, "gemini_reply") as call, mock.patch.object(app.gr, "Warning"):
drain(app.run_turn("ask", "Q?", session, creds))
call.assert_not_called()
self.assertIsNone(session.user_id, creds)
def test_show_image_shows_thinking_while_choosing(self):
session = signed_in_session(self.store)
session.state["topic"] = "topic"
reply = TutorReply(answer=f"Slide (Lecture {self.slide.lecture}, slide {self.slide.number}) shows it.",
slides=[SlideRef(lecture=self.slide.lecture, slide=self.slide.number)], covered_by_slides=True)
with mock.patch.object(app, "gemini_reply", return_value=reply):
outputs = self._show(session)
self.assertEqual(outputs[0][0][-1]["metadata"]["status"], "pending")
self.assertEqual(session.state["view"], [self.slide.lecture, self.slide.number])
self.assertIn("**Source:** Course lecture slides:", outputs[-1][0][-1]["content"])
self.assertEqual(self.store.events(), ["show_image"])
def _show(self, session):
request = SimpleNamespace(session_hash="tab-show")
with mock.patch.object(app, "get_session", return_value=session):
return list(app.on_show_image("", "", 0, request))
class RestoreTests(unittest.TestCase):
def test_restore_rebuilds_chat_and_state(self):
rows = [
{"role": "user", "content": "Q1", "mode": "ask", "topic": "t1", "source": None},
{"role": "assistant", "content": "A1" + app.SOURCE_MARK + "Course lecture slides.", "mode": "ask", "topic": "t1", "source": "slides"},
{"role": "user", "content": "Quiz me on: t1", "mode": "quiz", "topic": "t1", "source": None},
{"role": "assistant", "content": "Quiz" + app.SOURCE_MARK + "Course lecture slides.", "mode": "quiz", "topic": "t1", "source": "slides"},
]
messages, state = app._restore(rows)
self.assertEqual(messages[1], {"role": "assistant", "content": rows[1]["content"]})
self.assertEqual([h["role"] for h in state["history"]], ["user", "model", "user", "model"])
self.assertEqual(state["history"][1]["text"], "A1")
self.assertEqual(state["topic"], "t1")
self.assertEqual(state["last_answer"], "A1") # the quiz is skipped
self.assertEqual(state["last_note"], "Course lecture slides.")
self.assertEqual(state["last_source"], "slides")
self.assertIsNone(state["view"])
def test_restore_skips_failed_turns_and_show_notes(self):
rows = [
{"role": "user", "content": "Q1", "mode": "ask", "topic": "t1", "source": None},
{"role": "assistant", "content": "A1" + app.SOURCE_MARK + "Not found in the lecture slides, and no matching article was found on TeachMeAnatomy or Kenhub.",
"mode": "ask", "topic": "t1", "source": "none"},
{"role": "assistant", "content": "No slide in the lecture files illustrates this topic.", "mode": "show", "topic": "t1", "source": "none"},
{"role": "user", "content": "Q2", "mode": "ask", "topic": "t2", "source": None},
{"role": "assistant", "content": "The tutor is busy right now.", "mode": "ask", "topic": "t2", "source": "error"},
]
messages, state = app._restore(rows)
self.assertEqual(len(messages), 5) # everything is shown again
self.assertEqual([h["text"] for h in state["history"]], ["Q1", "A1"])
self.assertEqual(state["topic"], "t1")
self.assertEqual((state["last_answer"], state["last_source"]), ("A1", "none"))
class SignInTests(unittest.TestCase):
def setUp(self):
self.store = FakeStore()
self.patch = mock.patch.object(app, "STORE", self.store)
self.patch.start()
self.request = SimpleNamespace(session_hash="tab-1")
app.SESSIONS.clear()
def tearDown(self):
self.patch.stop()
app.SESSIONS.clear()
def test_create_account_then_sign_in_restores_history(self):
out = app.on_create_account("JDoe", "JDoe@Example.com", self.request)
self.assertFalse(out[0]["visible"]) # login panel hidden
self.assertTrue(out[1]["visible"]) # app shown
self.assertIn("Signed in as **jdoe** (jdoe@example.com).", out[3])
self.assertEqual(self.store.users["jdoe"]["email"], "jdoe@example.com")
self.assertEqual(self.store.events(), ["create_account"])
self.assertEqual([c[0] for c in self.store.calls][:2], ["start_session", "log_event"])
again = app.on_create_account("jdoe", "other@example.com", self.request)
self.assertTrue(again[0]["visible"])
self.assertIn("already taken", again[2])
session = app.get_session(self.request)
session.messages = [{"role": "user", "content": "Q"}, {"role": "assistant", "content": "A" + app.SOURCE_MARK + "Course lecture slides."}]
app._save(session, [{"role": "user", "content": "Q", "mode": "ask", "topic": "Q", "source": None},
{"role": "assistant", "content": session.messages[1]["content"], "mode": "ask", "topic": "Q", "source": "slides"}])
stored = session.messages[1]["content"]
out = app.on_sign_out(self.request)
self.assertTrue(out[0]["visible"])
self.assertEqual(out[5], [])
self.assertEqual(out[-3:], ("", "", 0)) # user ID and email fields cleared, page shows sign-in
self.assertEqual(len(out), len(app.login_outputs))
self.assertIsNone(session.user_id)
self.assertEqual([c[0] for c in self.store.calls][-2:], ["log_event", "end_session"])
self.assertEqual(self.store.events()[-1], "sign_out")
wrong = app.on_sign_in("jdoe", "wrong@example.com", self.request)
self.assertIn("does not match", wrong[2])
unknown = app.on_sign_in("nobody", "a@b.co", self.request)
self.assertIn("No account has this user ID", unknown[2])
out = app.on_sign_in("jdoe", "jdoe@example.com", self.request)
self.assertFalse(out[0]["visible"])
self.assertIn("2 earlier messages restored", out[3])
self.assertEqual([m["content"] for m in out[5]], ["Q", stored])
self.assertEqual(session.state["topic"], "Q")
self.assertTrue(out[-5]["interactive"]) # Ask enabled
self.assertTrue(out[-9]["interactive"]) # quick actions enabled: a topic exists
self.assertEqual(out[-1], session.version)
def test_validation_messages(self):
self.assertIn("user ID", app.on_sign_in("x", "a@b.co", self.request)[2])
self.assertIn("valid email", app.on_sign_in("jdoe", "not-an-email", self.request)[2])
with mock.patch.object(app, "STORE", FakeStore(enabled=False)):
self.assertIn("not configured", app.on_sign_in("jdoe", "a@b.co", self.request)[2])
def sync(self, version, user="jdoe", email="jdoe@example.com"):
return app.on_sync(version, user, email, self.request)
def test_sync_does_nothing_for_the_sign_in_form_or_an_up_to_date_page(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
self.assertEqual(self.sync(0), app.NOOP_SYNC) # page shows the sign-in form: never signs in
self.assertIsNone(app.get_session(self.request).user_id)
out = app.on_sign_in("jdoe", "jdoe@example.com", self.request)
self.assertEqual(self.sync(out[-1]), app.NOOP_SYNC)
def test_sync_delivers_an_answer_the_page_missed(self):
import threading
app.on_create_account("jdoe", "jdoe@example.com", self.request)
session = app.get_session(self.request)
shown = session.version # what the page has before it asks
slide, figure = first_text_slide_with_figure()
reply = TutorReply(answer=f"Fact (Lecture {slide.lecture}, slide {slide.number}).", slides=[], covered_by_slides=True)
release = threading.Event()
def slow_reply(*args):
release.wait(5)
return reply
with mock.patch.object(app, "gemini_reply", side_effect=slow_reply):
turn = app.run_turn("ask", "Q?", session, ("jdoe", "jdoe@example.com", shown))
pending = next(turn) # the page receives the Thinking message ...
self.assertFalse(pending[-3]["interactive"])
self.assertEqual(self.sync(shown), app.NOOP_SYNC) # ... the answer is being written: nothing to send
turn.close() # ... then its connection drops: Gradio drops the event
release.set()
for _ in range(100): # the answer is still finished and saved
if not session.busy:
break
app.time.sleep(0.02)
self.assertFalse(session.busy)
self.assertIn("append_messages", [c[0] for c in self.store.calls])
out = self.sync(shown)
self.assertEqual(len(out), len(app.sync_outputs))
self.assertEqual([m["content"].split(app.SOURCE_MARK)[0] for m in out[0]], ["Q?", reply.answer])
self.assertEqual(out[2], session.version)
self.assertTrue(all(update["interactive"] for update in out[-6:])) # buttons usable again
self.assertEqual(self.sync(out[2]), app.NOOP_SYNC) # nothing more to send
def test_a_multi_step_answer_finishes_after_the_page_is_gone(self):
session = app.get_session(self.request)
app.on_create_account("jdoe", "jdoe@example.com", self.request)
page = web_sources.Page("Kenhub", "https://www.kenhub.com/en/library/anatomy/retina", "Retina", "text")
slides_reply = TutorReply(answer="The slides do not cover this.", slides=[], covered_by_slides=False, search_topics=["retina"])
with mock.patch.object(app, "gemini_reply", return_value=slides_reply), \
mock.patch.object(web_sources, "find_pages", return_value=([page], "")), \
mock.patch.object(app, "gemini_web_reply", return_value=WebReply(answer="Ten layers (Kenhub: Retina).", covered=True, used_urls=[page.url])):
turn = app.run_turn("ask", "Layers of the retina?", session, ())
next(turn) # first progress step reaches the page
turn.close() # the page is gone before the second step
for _ in range(100):
if not session.busy:
break
app.time.sleep(0.02)
saved = [c for c in self.store.calls if c[0] == "append_messages"]
self.assertEqual(len(saved), 1)
self.assertEqual(saved[0][3][1]["source"], "web")
self.assertTrue(session.messages[-1]["content"].startswith("Ten layers (Kenhub: Retina)."))
def test_sync_restores_the_student_after_a_server_restart(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
self.store.messages = [{"user_id": "jdoe", "role": "user", "content": "Q1", "mode": "ask", "topic": "t1", "source": None},
{"user_id": "jdoe", "role": "assistant", "content": "A1", "mode": "ask", "topic": "t1", "source": "slides"}]
self.assertEqual(self.sync(1791000000000, email="wrong@example.com"), app.NOOP_SYNC)
out = self.sync(1791000000000)
session = app.get_session(self.request)
self.assertEqual(session.user_id, "jdoe")
self.assertEqual([m["content"] for m in out[0]], ["Q1", "A1"])
self.assertIn("The site restarted; your chat was restored.", out[3])
self.assertEqual(self.store.events(), ["resume"])
def test_reloaded_tab_signs_itself_back_in_and_keeps_the_draft(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
self.store.messages = [{"user_id": "jdoe", "role": "user", "content": "Q1", "mode": "ask", "topic": "t1", "source": None},
{"user_id": "jdoe", "role": "assistant", "content": "A1", "mode": "ask", "topic": "t1", "source": "slides"}]
steps = list(app.on_page_load("JDoe", "jdoe@example.com", "half-typed question", self.request))
self.assertEqual(steps[0][2], "Reconnecting and restoring your chat...")
final = steps[-1]
self.assertEqual(len(final), len(app.login_outputs))
self.assertFalse(final[0]["visible"]) # sign-in form hidden
self.assertTrue(final[1]["visible"]) # chat shown
self.assertIn("Reconnected; 2 messages restored.", final[3])
self.assertEqual([m["content"] for m in final[5]], ["Q1", "A1"])
self.assertEqual(final[6], "half-typed question")
self.assertEqual(final[-3:], ("jdoe", "jdoe@example.com", app.get_session(self.request).version))
self.assertEqual(self.store.events(), ["resume"])
def test_reloaded_tab_drops_a_draft_that_was_already_answered(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
self.store.messages = [{"user_id": "jdoe", "role": "user", "content": "Q1", "mode": "ask", "topic": "t1", "source": None},
{"user_id": "jdoe", "role": "assistant", "content": "A1", "mode": "ask", "topic": "t1", "source": "slides"}]
final = list(app.on_page_load("jdoe", "jdoe@example.com", "Q1", self.request))[-1]
self.assertEqual(final[6], "")
def test_page_load_without_a_remembered_sign_in_shows_the_form(self):
self.assertEqual(list(app.on_page_load("", "", "", self.request)), [app.NOOP_LOGIN])
steps = list(app.on_page_load("jdoe", "wrong@example.com", "", self.request))
self.assertTrue(steps[-1][0]["visible"])
self.assertIn("could not be restored automatically", steps[-1][2])
self.assertIsNone(app.get_session(self.request).user_id)
def test_reloaded_page_waits_for_the_old_tab_and_ordinary_sign_ins_do_not(self):
import threading
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
old = app.get_session(SimpleNamespace(session_hash="old-tab"))
old.user_id, old.busy = "jdoe", True
old.messages = [{"role": "user", "content": "Pending question?"}]
def finish():
app.time.sleep(1.5)
old.saved_at, old.busy = app.time.monotonic(), False
threading.Thread(target=finish).start()
started = app.time.monotonic()
steps = list(app.on_page_load("jdoe", "jdoe@example.com", "Pending question?", self.request))
self.assertGreaterEqual(app.time.monotonic() - started, 1.0) # waited for the old tab
self.assertGreater(len(steps), 2) # by polling, one step per second
names = [c[0] for c in self.store.calls]
self.assertLess(names.index("flush"), names.index("start_session")) # its answer reaches Supabase first
# A plain sign-in on another tab does not wait for the shared write queue.
self.store.calls.clear()
app.on_sign_in("jdoe", "jdoe@example.com", SimpleNamespace(session_hash="tab-2"))
self.assertIn("flush", [c[0] for c in self.store.calls]) # the old tab saved just now
old.saved_at = 0.0
self.store.calls.clear()
app.on_sign_in("jdoe", "jdoe@example.com", SimpleNamespace(session_hash="tab-3"))
self.assertNotIn("flush", [c[0] for c in self.store.calls])
def test_an_answer_still_being_written_reaches_the_reloaded_page_later(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
old = app.get_session(SimpleNamespace(session_hash="old-tab"))
old.user_id, old.busy = "jdoe", True
old.messages = [{"role": "user", "content": "Slow question?"}]
with mock.patch.object(app, "ANSWER_WAIT_SECONDS", 0.2):
final = list(app.on_page_load("jdoe", "jdoe@example.com", "Slow question?", self.request))[-1]
self.assertEqual(final[6], "") # not put back: still being answered
session = app.get_session(self.request)
self.assertTrue(session.awaiting)
self.assertEqual(self.sync(final[-1]), app.NOOP_SYNC) # still being written
self.store.messages = [{"user_id": "jdoe", "role": "user", "content": "Slow question?", "mode": "ask", "topic": "s", "source": None},
{"user_id": "jdoe", "role": "assistant", "content": "Slow answer.", "mode": "ask", "topic": "s", "source": "slides"}]
old.busy, old.saved_at = False, app.time.monotonic()
out = self.sync(final[-1])
self.assertEqual([m["content"] for m in out[0]], ["Slow question?", "Slow answer."])
self.assertFalse(session.awaiting)
def test_a_sign_in_made_while_reconnecting_is_not_overwritten(self):
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
self.store.users["bob"] = {"user_id": "bob", "email": "bob@example.com"}
old = app.get_session(SimpleNamespace(session_hash="old-tab"))
old.user_id, old.busy = "jdoe", True
load = app.on_page_load("jdoe", "jdoe@example.com", "", self.request)
next(load) # "Reconnecting..." on the sign-in form
app.on_sign_in("bob", "bob@example.com", self.request) # bob signs in on this tab meanwhile
old.busy = False
steps = list(load)
self.assertEqual(steps[-1], app.NOOP_LOGIN)
self.assertEqual(app.get_session(self.request).user_id, "bob")
def test_sign_out_is_final_for_requests_already_in_flight(self):
out = app.on_sign_in("jdoe", "jdoe@example.com", self.request) if "jdoe" in self.store.users else None
self.store.users["jdoe"] = {"user_id": "jdoe", "email": "jdoe@example.com"}
out = app.on_sign_in("jdoe", "jdoe@example.com", self.request)
version = out[-1]
app.on_sign_out(self.request)
self.assertEqual(self.sync(version), app.NOOP_SYNC) # a tick sent before Sign out
with mock.patch.object(app, "gemini_reply") as call, mock.patch.object(app.gr, "Warning"):
drain(app.run_turn("ask", "Q?", app.get_session(self.request), ("jdoe", "jdoe@example.com", version)))
call.assert_not_called()
self.assertIsNone(app.get_session(self.request).user_id)
app.on_sign_in("jdoe", "jdoe@example.com", self.request) # signing in again works
self.assertEqual(app.get_session(self.request).user_id, "jdoe")
def test_sign_out_while_an_answer_is_written_says_so(self):
app.on_create_account("jdoe", "jdoe@example.com", self.request)
session = app.get_session(self.request)
session.busy = True
with mock.patch.object(app.gr, "Warning") as warning:
self.assertEqual(app.on_sign_out(self.request), app.NOOP_LOGIN)
warning.assert_called_once()
self.assertEqual(session.user_id, "jdoe")
def test_evicting_a_closed_visit_does_not_close_it_again(self):
app.on_create_account("jdoe", "jdoe@example.com", self.request)
app.forget_session(self.request)
with mock.patch.object(app, "MAX_SESSIONS", 1):
app.get_session(SimpleNamespace(session_hash="someone-else"))
self.assertEqual([c[0] for c in self.store.calls].count("end_session"), 1)
def test_time_on_site_and_new_visit_after_the_connection_drops(self):
app.on_create_account("jdoe", "jdoe@example.com", self.request)
session = app.get_session(self.request)
first = session.session_id
self.sync(session.version)
self.assertNotIn("heartbeat", [c[0] for c in self.store.calls]) # written at most every HEARTBEAT_SECONDS
session.last_beat -= app.HEARTBEAT_SECONDS
self.sync(session.version)
self.assertEqual(self.store.calls[-1], ("heartbeat", first))
app.forget_session(self.request) # laptop asleep: the visit is closed
self.assertEqual(self.store.calls[-1], ("end_session", first))
app.forget_session(self.request) # closed once only
self.assertEqual([c[0] for c in self.store.calls].count("end_session"), 1)
self.assertEqual(session.user_id, "jdoe") # still signed in on the server
self.sync(session.version) # back: a new visit starts
self.assertNotEqual(session.session_id, first)
self.assertEqual(self.store.calls[-1], ("start_session", session.session_id, "jdoe"))
class WebSourceTests(unittest.TestCase):
def test_article_filters(self):
tma, kenhub = web_sources.SITES
self.assertTrue(tma.article.match("https://teachmeanatomy.info/upper-limb/nerves/brachial-plexus/"))
self.assertFalse(tma.article.match("https://teachmeanatomy.info/upper-limb/nerves/")) # category index
self.assertFalse(tma.article.match("https://teachmeanatomy.info/encyclopaedia/d/"))
self.assertFalse(tma.article.match("https://teachmeanatomy.info/shop/"))
self.assertFalse(tma.article.match("https://teachmeanatomy.info/es/miembro-superior/nervios/plexo-braquial/"))
self.assertFalse(tma.article.match("https://teachmeanatomy.info/3d-model/upper-limb/"))
self.assertTrue(kenhub.article.match("https://www.kenhub.com/en/library/anatomy/brachial-plexus"))
self.assertTrue(kenhub.article.match("https://www.kenhub.com/en/library/education/the-human-anatomy"))
self.assertFalse(kenhub.article.match("https://www.kenhub.com/en/library/learning-strategies/how-to-study"))
self.assertFalse(kenhub.article.match("https://www.kenhub.com/de/library/anatomy/plexus-brachialis"))
def test_headings_stay_on_one_line_and_clinical_sections_survive_the_cut(self):
self.assertEqual(web_sources.article_text("<article><h2>\r\n Clinical\r\n significance </h2><p>x</p></article>"),
"## Clinical significance\n\nx")
text = "## Anatomy\n" + "a" * 20000 + "\n## Clinical significance\nACL injury.\n### Terrible triad\nThree.\n## Sources\nRefs."
cut = web_sources.excerpt(text, 1000)
self.assertLessEqual(len(cut), 1000)
self.assertTrue(cut.startswith("## Anatomy\naaa"))
self.assertTrue(cut.endswith("[...]\n\n## Clinical significance\nACL injury.\n### Terrible triad\nThree."))
self.assertEqual(web_sources.excerpt("short", 1000), "short")
def test_article_text_keeps_the_article_only(self):
page = ("<html><head><title>Retina | Kenhub</title><script>x()</script></head><body><nav>Menu</nav>"
"<article><h1>Retina</h1><p>The retina has <b>ten</b> layers.</p><ul><li>Layer one</li><li>Layer two</li></ul>"
"<figure>caption</figure></article><footer>Footer</footer></body></html>")
text = web_sources.article_text(page)
self.assertEqual(text, "# Retina\n\nThe retina has ten layers.\n\n- Layer one\n- Layer two")
self.assertEqual(web_sources.page_title(page, "Kenhub"), "Retina")
self.assertEqual(web_sources.page_title("<title>Eye | Kenhub</title>", "Kenhub"), "Eye")
@staticmethod
def catalog(urls):
entries = [("TeachMeAnatomy" if "teachmeanatomy" in u else "Kenhub", u) for u in urls]
return web_sources.Catalog(
data=(entries, web_sources.BM25([web_sources.url_tokens(u) for _, u in entries]),
[frozenset(web_sources.words(web_sources.url_path(u))) for _, u in entries]),
next_load=float("inf"))
def test_find_pages_matches_each_structure_and_never_a_generic_word(self):
tma, kh = "https://teachmeanatomy.info/", "https://www.kenhub.com/en/library/anatomy/"
catalog = self.catalog([
tma + "lower-limb/joints/knee-joint/", tma + "lower-limb/joints/ankle-joint/", tma + "upper-limb/joints/shoulder/",
tma + "lower-limb/areas/tarsal-tunnel/", tma + "upper-limb/areas/carpal-tunnel/",
kh + "the-knee-joint", kh + "the-ankle-joint", kh + "upper-and-lower-brachial-plexus-injury", kh + "colles-fracture",
kh + "carpal-tunnel", kh + "superior-thoracic-artery", kh + "retina", kh + "central-retinal-artery", kh + "medial-meniscus",
])
def find(topics):
def load(page): # the page text names the structure in its URL
return web_sources.Page(page.site, page.url, "T", web_sources.url_path(page.url))
with mock.patch.object(web_sources, "CATALOG", catalog), mock.patch.object(web_sources, "load_page", side_effect=load):
pages, error = web_sources.find_pages(topics)
self.assertEqual(error, "")
return [p.url.replace(tma, "TMA:").replace(kh, "KH:") for p in pages]
self.assertEqual(find(["knee joint", "ankle joint"]),
["TMA:lower-limb/joints/knee-joint/", "TMA:lower-limb/joints/ankle-joint/", "KH:the-knee-joint", "KH:the-ankle-joint"])
self.assertEqual(find(["knee joint injuries", "ankle fracture"]), # injury/fracture never select a page
["TMA:lower-limb/joints/knee-joint/", "TMA:lower-limb/joints/ankle-joint/", "KH:the-knee-joint", "KH:the-ankle-joint"])
self.assertEqual(find(["injury"]), [])
self.assertEqual(find(["tarsal tunnel"]), ["TMA:lower-limb/areas/tarsal-tunnel/"]) # not the carpal tunnel
self.assertEqual(find(["superior thoracic aperture"]), []) # not the superior thoracic artery
self.assertEqual(find(["retinal layers"]), ["KH:retina"]) # the retina before the artery
self.assertEqual(find(["menisci"]), ["KH:medial-meniscus"])
self.assertEqual(find("knee"), ["TMA:lower-limb/joints/knee-joint/", "KH:the-knee-joint"])
self.assertLessEqual(len(find(["knee", "ankle", "shoulder", "retina"])), web_sources.MAX_PAGES)
with mock.patch.object(web_sources, "CATALOG", catalog), mock.patch.object(web_sources, "load_page", return_value=None):
pages, error = web_sources.find_pages(["retina"])
self.assertEqual(pages, [])
self.assertEqual(error, "the matching TeachMeAnatomy and Kenhub pages could not be downloaded")
class WordTests(unittest.TestCase):
def test_singular_and_joint_names(self):
self.assertEqual(web_sources.words("arches sinuses menisci vertebrae trapezius trapezium pelvis processes talocrural"),
["arch", "sinus", "meniscus", "vertebra", "trapezius", "trapezium", "pelvis", "process", "ankle"])
self.assertFalse(web_sources.same_word("trapezius", "trapezium"))
self.assertTrue(web_sources.same_word("retina", "retinal"))
def test_type_word_must_agree_and_be_mentioned(self):
kh = "https://www.kenhub.com/en/library/anatomy/"
catalog = WebSourceTests.catalog([kh + "the-deltoid-muscle", kh + "the-ankle-joint"])
entries, index, slug_words = catalog.data
self.assertEqual(web_sources.topic_matches("deltoid ligament", entries, index, slug_words), [])
self.assertEqual(web_sources.topic_matches("deltoid muscle", entries, index, slug_words), [0])
page = web_sources.Page("Kenhub", kh + "x", "Deltoid", "The deltoid is a muscle of the shoulder.")
self.assertFalse(web_sources.names_structure("deltoid ligament", page))
page.text = "The medial (deltoid) ligament of the ankle."
self.assertTrue(web_sources.names_structure("deltoid ligament", page))
self.assertTrue(web_sources.names_structure("brachial plexus", page)) # no type word: always accepted
def test_slide_names_reach_the_article_names(self):
kh = "https://www.kenhub.com/en/library/anatomy/"
catalog = WebSourceTests.catalog([kh + "achilles-tendon", kh + "calcaneal-tuberosity"])
load = lambda page: web_sources.Page(page.site, page.url, "Achilles tendon", "The achilles tendon (calcaneal tendon) ...")
with mock.patch.object(web_sources, "CATALOG", catalog), mock.patch.object(web_sources, "load_page", side_effect=load):
pages, _ = web_sources.find_pages(["calcaneal tendon"])
self.assertEqual([p.url for p in pages], [kh + "achilles-tendon"])
def test_question_fallback_splits_structures(self):
kh = "https://www.kenhub.com/en/library/anatomy/"
catalog = WebSourceTests.catalog([kh + "the-knee-joint", kh + "the-ankle-joint", kh + "carpal-tunnel"])
with mock.patch.object(web_sources, "CATALOG", catalog):
self.assertEqual(web_sources.question_topics("What are the ligaments of the knee and ankle?"), ["knee", "ankle"])
self.assertEqual(web_sources.question_topics("Tell me about the carpal tunnel"), ["Tell me about the carpal tunnel"])
class RetrievalTests(unittest.TestCase):
def test_question_words_do_not_match_slides(self):
self.assertEqual(app.tokenize("What are the ligaments of the knees?"), ["ligament", "knee"])
def test_knee_and_ankle_questions_attach_no_upper_limb_slides(self):
knee = next(n for n, lec in app.LECTURES.items() if "knee" in lec.title.lower())
ankle = next(n for n, lec in app.LECTURES.items() if "ankle" in lec.title.lower())
upper_limb = {n for n, lec in app.LECTURES.items()
if any(w in lec.title.lower() for w in ("shoulder", "brachial", "arm", "forearm", "hand", "axillary"))}
self.assertTrue(upper_limb)
for question in ["What are the ligaments of the knee and ankle?", "What about the knees and ankles?",
"What are the bones of the knee joint?", "What is the Ottawa ankle rule?",
"How is the ankle joint stabilized?", "What causes knee and ankle injuries?"]:
lectures = [slide.lecture for slide in app.retrieve_slides(question)]
self.assertFalse(set(lectures) & upper_limb, (question, lectures))
self.assertGreaterEqual(sum(n in (knee, ankle) for n in lectures), len(lectures) / 2, (question, lectures))
class StoreTests(unittest.TestCase):
def test_validators(self):
self.assertTrue(supabase_store.is_valid_user_id(" JDoe_01 "))
self.assertFalse(supabase_store.is_valid_user_id("j"))
self.assertFalse(supabase_store.is_valid_user_id("j doe"))
self.assertTrue(supabase_store.is_valid_email("A.B@Example.co"))
self.assertFalse(supabase_store.is_valid_email("a@b"))
self.assertEqual(supabase_store.normalize_user_id(" JDoe "), "jdoe")
def test_requests_use_the_supabase_rest_layout(self):
store = supabase_store.SupabaseStore(url="https://abc.supabase.co/", key="k")
seen = []
def fake_urlopen(request, timeout=None):
seen.append(request)
return io.BytesIO(b'[{"user_id": "jdoe", "email": "j@d.co"}]')
with mock.patch.object(urllib.request, "urlopen", fake_urlopen):
row, error = store.get_user("JDoe")
self.assertEqual((row["email"], error), ("j@d.co", None))
self.assertEqual(seen[0].full_url, "https://abc.supabase.co/rest/v1/anatomy_users?select=user_id%2Cemail%2Ccreated_at%2Clast_login_at&user_id=eq.jdoe&limit=1")
self.assertEqual(seen[0].get_header("Apikey"), "k")
self.assertEqual(seen[0].get_header("Authorization"), "Bearer k")
with mock.patch.object(urllib.request, "urlopen", fake_urlopen):
ok, error = store.append_messages("jdoe", "s1", [{"role": "user", "content": "Q", "mode": "ask", "topic": "Q"}, {"role": "bad"}])
self.assertTrue(ok)
body = json.loads(seen[-1].data)
self.assertEqual(len(body), 1)
self.assertEqual(set(body[0]), {"user_id", "session_id", "role", "content", "mode", "topic", "source", "created_at"})
self.assertEqual(seen[-1].get_header("Prefer"), "return=minimal")
def test_duplicate_user_id_is_reported_as_exists(self):
store = supabase_store.SupabaseStore(url="https://abc.supabase.co", key="k")
error = urllib.error.HTTPError("u", 409, "Conflict", {}, io.BytesIO(b'{"code":"23505"}'))
with mock.patch.object(urllib.request, "urlopen", side_effect=error):
row, err = store.create_user("jdoe", "j@d.co")
self.assertEqual((row, err), (None, supabase_store.EXISTS))
def test_inserts_are_not_retried_but_reads_are(self):
store = supabase_store.SupabaseStore(url="https://abc.supabase.co", key="k", timeout=1)
calls = []
def flaky(request, timeout=None):
calls.append(request.get_method())
raise TimeoutError("timed out")
with mock.patch.object(urllib.request, "urlopen", side_effect=flaky), mock.patch.object(supabase_store.time, "sleep"):
ok, error = store.log_event("jdoe", "s1", "ask", {})
self.assertFalse(ok)
self.assertIn("TimeoutError", error)
row, error = store.get_user("jdoe")
self.assertEqual(calls, ["POST"] + ["GET"] * supabase_store.RETRIES)
def test_disabled_store_is_harmless(self):
store = supabase_store.SupabaseStore(url="", key="")
self.assertFalse(store.enabled)
self.assertEqual(store.get_user("x"), (None, "Missing Space secret(s): SUPABASE_URL, SUPABASE_KEY"))
store.enqueue("log_event", "x", "s", "ask", {})
self.assertTrue(store.flush(0.1))
if __name__ == "__main__":
unittest.main()
|