3434 fetch_student_answers_in_timerange ,
3535 count_distinct_student_answers ,
3636 count_peer_messages ,
37+ fetch_last_useinfo_peergroup ,
3738)
3839from rsptx .db .models import UseinfoValidation
3940from 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