Skip to content

Commit a2777c6

Browse files
committed
Add A/B testing
1 parent 21ca922 commit a2777c6

1 file changed

Lines changed: 141 additions & 22 deletions

File tree

  • bases/rsptx/assignment_server_api/routers

bases/rsptx/assignment_server_api/routers/peer.py

Lines changed: 141 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
fetch_student_answers_in_timerange,
3535
count_distinct_student_answers,
3636
count_peer_messages,
37+
fetch_last_useinfo_peergroup,
3738
)
3839
from rsptx.db.models import UseinfoValidation
3940
from rsptx.auth.session import auth_manager
@@ -486,8 +487,11 @@ async def make_pairs(
486487
user=Depends(auth_manager),
487488
course=None,
488489
):
489-
"""
490-
Create student pairs/groups for peer instruction based on their answers.
490+
"""Create student pairs/groups for peer instruction based on their answers.
491+
492+
When ``is_ab`` is true, split students into in-person groups (based on prior
493+
``peergroup`` useinfo events) and chat groups to support A/B experimentation,
494+
mimicking the legacy web2py behavior.
491495
"""
492496
import os
493497
import redis
@@ -501,10 +505,10 @@ async def make_pairs(
501505

502506
# Get student answers from the database
503507
st = parse(start_time)
504-
start_time = st.replace(tzinfo=None)
508+
start_time_dt = st.replace(tzinfo=None)
505509

506510
rows = await fetch_recent_student_answers(
507-
div_id, course.course_name, start_time
511+
div_id, course.course_name, start_time_dt
508512
)
509513

510514
# Create a dictionary of student answers
@@ -515,51 +519,136 @@ async def make_pairs(
515519
if user.username in peeps:
516520
peeps.remove(user.username)
517521

518-
# Shuffle the list
522+
# Shuffle the list of students
519523
random.shuffle(peeps)
520524

521-
# Create groups
522-
group_list = []
523-
524-
while peeps:
525-
# Start a new group
525+
group_list: list[list[str]] = []
526+
in_person_groups: list[set[str]] = []
527+
peeps_in_person: list[str] = []
528+
529+
# A/B logic: split into in-person vs chat groups using prior peergroup info
530+
if is_ab:
531+
# Build in-person groups from last peergroup entries
532+
useinfos = await fetch_last_useinfo_peergroup(course.course_name)
533+
in_person_groups = []
534+
for u in useinfos:
535+
# act format peergroup:student1,student2,...
536+
try:
537+
_, members = u.act.split(":", 1)
538+
grp = set(members.split(","))
539+
if u.sid not in grp:
540+
grp.add(u.sid)
541+
in_person_groups.append(grp)
542+
except Exception: # defensive; malformed rows shouldn't break pairing
543+
continue
544+
545+
def find_set_containing_string(
546+
list_of_sets: list[set[str]], target: str
547+
) -> set[str]:
548+
result: set[str] = set()
549+
for s in list_of_sets:
550+
if target in s:
551+
result |= s
552+
return result
553+
554+
def process_peep(
555+
sid: str,
556+
remaining: list[str],
557+
target_list: list[str],
558+
other_list: list[str],
559+
local_groups: list[set[str]],
560+
mode: str,
561+
) -> None:
562+
target_list.append(sid)
563+
if sid in remaining:
564+
remaining.remove(sid)
565+
other_peeps = find_set_containing_string(local_groups, sid)
566+
# If no other peeps then this person must be put into a chat group,
567+
# not an in-person group.
568+
if not other_peeps and mode == "in_person":
569+
other_list.append(sid)
570+
return
571+
for op in other_peeps:
572+
if op in remaining:
573+
remaining.remove(op)
574+
if op not in target_list:
575+
target_list.append(op)
576+
577+
peeps_in_chat: list[str] = []
578+
peep_queue = [p for p in peeps if p in sid_ans]
579+
while peep_queue:
580+
p = peep_queue.pop()
581+
if p in peeps_in_person or p in peeps_in_chat:
582+
continue
583+
if random.random() < 0.5:
584+
rslogger.debug(f"Adding {p} to the in_person list")
585+
process_peep(
586+
p,
587+
peeps,
588+
peeps_in_person,
589+
peeps_in_chat,
590+
in_person_groups,
591+
"in_person",
592+
)
593+
else:
594+
process_peep(
595+
p,
596+
peeps,
597+
peeps_in_chat,
598+
peeps_in_person,
599+
in_person_groups,
600+
"chat",
601+
)
602+
# Need to ensure that chat peeps have answered the question
603+
peeps = [p for p in peeps_in_chat if p in sid_ans]
604+
rslogger.debug(f"FINAL PEEPS IN CHAT = {peeps}")
605+
rslogger.debug(f"FINAL PEEPS IN PERSON = {peeps_in_person}")
606+
607+
# Chat pairing for the remaining students in `peeps`
608+
done = len(peeps) == 0
609+
while not done:
610+
# Start a new group with one student
526611
group = [peeps.pop()]
527612

528-
# Try to add more students with different answers
529-
for i in range(group_size - 1):
613+
# Try to add more students to the group with different answers
614+
for _ in range(group_size - 1):
530615
if not peeps:
531616
break
532-
533-
# Try to find someone with a different answer
534617
first_answer = sid_ans.get(group[0])
535-
partner_idx = 0
618+
# Find a partner with a different answer if possible
619+
partner_idx = None
536620
for idx, p in enumerate(peeps):
537621
if sid_ans.get(p) != first_answer:
538622
partner_idx = idx
539623
break
540-
624+
if partner_idx is None:
625+
partner_idx = 0
541626
group.append(peeps.pop(partner_idx))
542627

543-
# If group has only one student, add to previous group
628+
# If the group only has one student, add them to the previous group
544629
if len(group) == 1 and group_list:
545630
group_list[-1].append(group[0])
546631
else:
547632
group_list.append(group)
548633

549-
# Create partner dictionary
550-
gdict = {}
634+
# Stop if all students have been grouped
635+
if len(peeps) == 0:
636+
done = True
637+
638+
# Create partner dictionary for chat groups
639+
gdict: dict[str, list[str]] = {}
551640
for group in group_list:
552641
for p in group:
553642
partners = [partner for partner in group if partner != p]
554643
gdict[p] = partners
555644

556-
# Save to Redis
645+
# Save chat groups to Redis
557646
for k, v in gdict.items():
558647
r.hset(f"partnerdb_{course.course_name}", k, json.dumps(v))
559648

560649
r.hset(f"{course.course_name}_state", "mess_count", "0")
561650

562-
# Broadcast partner information
651+
# Broadcast partner information for chat (enableChat)
563652
for sid, answer in sid_ans.items():
564653
partners = gdict.get(sid, [])
565654
mess = {
@@ -575,7 +664,37 @@ async def make_pairs(
575664
}
576665
r.publish("peermessages", json.dumps(mess))
577666

578-
rslogger.info(f"Created {len(group_list)} groups")
667+
# If doing A/B, also send face-chat groups based on in-person groups
668+
if is_ab and peeps_in_person:
669+
# Build a map of username -> full name for this course
670+
from rsptx.db.crud import fetch_course_students
671+
672+
students = await fetch_course_students(course.id)
673+
peeps_dict = {
674+
s.username: f"{getattr(s, 'first_name', '')} {getattr(s, 'last_name', '')}".strip()
675+
for s in students
676+
}
677+
678+
for p in peeps_in_person:
679+
pgroup: set[str] = set()
680+
for grp in in_person_groups:
681+
if p in grp:
682+
pgroup = grp
683+
break
684+
# Convert usernames to display names when possible
685+
display_group = [peeps_dict.get(x, x) for x in pgroup]
686+
mess = {
687+
"type": "control",
688+
"from": p,
689+
"to": p,
690+
"message": "enableFaceChat",
691+
"broadcast": False,
692+
"group": display_group,
693+
"course_name": course.course_name,
694+
}
695+
r.publish("peermessages", json.dumps(mess))
696+
697+
rslogger.info(f"Created {len(group_list)} chat groups (is_ab={is_ab})")
579698
return JSONResponse(content={"status": "success", "groups": len(group_list)})
580699

581700
except Exception as e:

0 commit comments

Comments
 (0)