bethanie05 commited on
Commit
5b8e1c7
·
verified ·
1 Parent(s): 737f332

Add latest version of main.py

Browse files
Files changed (1) hide show
  1. chat_application/main.py +649 -0
chat_application/main.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, render_template, redirect, url_for, session, make_response, render_template_string
2
+ from flask_socketio import SocketIO, join_room, leave_room, send
3
+ from pymongo import MongoClient
4
+ from datetime import datetime, timedelta
5
+ import random
6
+ import time
7
+ import math
8
+ import google.auth
9
+ from google.auth.transport.requests import AuthorizedSession
10
+ from vertexai.tuning import sft
11
+ from vertexai.generative_models import GenerativeModel
12
+ import re
13
+ import concurrent.futures
14
+ from google import genai
15
+ from google.genai.types import GenerateContentConfig, HttpOptions
16
+ from text_corruption import corrupt
17
+ from humanizing import humanize
18
+ from quote_removal import remove_quotes
19
+ from weird_char_removal import remove_weird_characters
20
+ from duplicate_detection import duplicate_check
21
+
22
+ #controls
23
+ CHAT_CONTEXT = 20 #how many messages from chat history to append to inference prompt
24
+ #minimum number of chars where we start checking for duplicate messages
25
+ DUP_LEN = 25 #since short messages may reasonably be the same
26
+
27
+ app = Flask(__name__)
28
+ app.config["SECRET_KEY"] = "supersecretkey"
29
+ socketio = SocketIO(app)
30
+
31
+ # Setup for Vertex API calls
32
+ credentials, _ = google.auth.default(
33
+ scopes=["https://www.googleapis.com/auth/cloud-platform"]
34
+ )
35
+ google_session = AuthorizedSession(credentials)
36
+ vertex_client = genai.Client(
37
+ vertexai=True,
38
+ project="frozone-475719",
39
+ location="us-central1"
40
+ )
41
+ """
42
+ #original lines before separating system instructions and prompts
43
+ # Initialize the bots
44
+ pirate_tuning_job_name = f"projects/frozone-475719/locations/us-central1/tuningJobs/3296615187565510656"
45
+ tuning_job_frobot = f"projects/frozone-475719/locations/us-central1/tuningJobs/1280259296294076416"
46
+ tuning_job_hotbot = f"projects/frozone-475719/locations/us-central1/tuningJobs/4961166390611410944"
47
+ tuning_job_coolbot = f"projects/frozone-475719/locations/us-central1/tuningJobs/4112237860852072448"
48
+
49
+ hottj = sft.SupervisedTuningJob(tuning_job_hotbot)
50
+ cooltj = sft.SupervisedTuningJob(tuning_job_coolbot)
51
+ frotj = sft.SupervisedTuningJob(tuning_job_frobot)
52
+ # Create the bot models
53
+
54
+ hotbot = GenerativeModel(hottj.tuned_model_endpoint_name)
55
+ coolbot = GenerativeModel(cooltj.tuned_model_endpoint_name)
56
+ frobot = GenerativeModel(frotj.tuned_model_endpoint_name)
57
+ """
58
+ #change to endpoints
59
+ hotbot = "projects/frozone-475719/locations/us-central1/endpoints/34576342158671872"
60
+ coolbot = "projects/700531062565/locations/us-central1/endpoints/827209876575879168"
61
+ frobot = "projects/700531062565/locations/us-central1/endpoints/6323923590525747200"
62
+
63
+ # MongoDB setup
64
+ client = MongoClient("mongodb://localhost:27017/")
65
+ db = client["experimentData"]
66
+ rooms_collection = db.rooms
67
+
68
+ # List of fruits to choose display names from
69
+ FRUIT_NAMES = ["blueberry", "strawberry", "orange", "cherry"]
70
+ aliases = {"watermelon":"W", "apple":"L", "banana":"B", "blueberry":"C", "strawberry":"D", "orange":"E", "grape":"G", "cherry":"H"}
71
+ reverse_aliases = { value:key for key,value in aliases.items() }
72
+ # List of discussion topics
73
+ TOPICS_LIST = [
74
+ {
75
+ "title": "Abortion",
76
+ "text": "Since the Supreme Court overturned Roe vs. Wade in 2022, there has been an increase in patients crossing state lines to receive abortions in less restrictive states. Pro-choice advocates argue that these restrictions exacerbate unequal access to healthcare due to financial strain and other factors and believe that a patient should be able to make personal medical decisions about their own body and future. Pro-life advocates argue that abortion legislation should be left to the states and believe that abortion is amoral and tantamount to murder. Both sides disagree on how to handle cases of rape, incest, terminal medical conditions, and risks to the mother’s life and health. What stance do you take on abortion and why?",
77
+ "post": "Idk its hard bc both sides have good points. People should be able to make their own decisions about their own body but theres also moral stuff to think about too you know"
78
+ },
79
+ {
80
+ "title": "Gun Rights/Control",
81
+ "text": "Gun rights advocates argue that the right to bear arms is a protected second amendment right necessary for self-defense. Meanwhile, gun control advocates argue that stricter regulations are necessary to reduce gun violence. Potential reforms include stricter background checks, banning assault weapons, enacting red flag laws, and increasing the minimum age to purchase a gun. What stance do you take on gun rights vs. gun control and why?",
82
+ "post": "i think people should be able to own guns but there has to be some check like background stuff so crazy people dont get them"
83
+ },
84
+ {
85
+ "title": "Education and Trans Students",
86
+ "text": "Laws and policies affecting trans people are highly contested, especially those involving education. Several states have passed laws restricting the use of preferred pronouns and names in schools, limiting transgender athletes' ability to participate in sports, and banning books containing LGBTQ+ content from school libraries. How do you think decisions on school policies regarding trans students should be made and why?",
87
+ "post": "I dont think its that big a deal to use different pronouns but also trans athletes should be playing with the gender they were born as. I know thats an unpopular opinion but its the only way its fair."
88
+ },
89
+ {
90
+ "title": "Immigration and ICE Activity",
91
+ "text": "The current year has seen an increase in ICE (U.S. Immigration and Customs Enforcement) activity, including raids at workplaces, courthouses, schools, churches, and hospitals. Some argue that ICE is going too far and is violating the Constitutional due process rights of both immigrants and citizens. Others argue that these actions are necessary to maintain national security and enforce immigration law. What stance do you take on recent ICE activity and why?",
92
+ "post": "I think ice is doing their job they're literally immigration enforcement. It sucks but if you come here illegally youre going to face the consequence."
93
+ },
94
+ {
95
+ "title": "Universal Healthcare",
96
+ "text": "Some argue that universal healthcare is necessary to ensure everyone has access to lifesaving medical treatments and a minimum standard of living, regardless of income or employment. Others argue that the choice of how to access healthcare is a private responsibility and that it is more efficient for the government to limit intervention. What stance do you take on government involvement in providing healthcare and why?",
97
+ "post": "I think people should handle their own healthcare. the government is slow plus competition means more innovation. i dont trust the idea of one size fits all"
98
+ }
99
+ ]
100
+
101
+ # FroBot Main Prompt
102
+ with open("../data/prompts/frobot_prompt_main.txt") as f:
103
+ FROBOT_PROMPT = f.read()
104
+ # Instructions
105
+ with open("../data/inference_instructions/frobot_instructions_main.txt") as f:
106
+ FROBOT_INSTRUCT = f.read()
107
+
108
+ # HotBot Prompt
109
+ with open("../data/prompts/hotbot_prompt_main.txt") as h:
110
+ HOTBOT_PROMPT = h.read()
111
+ # Instructions
112
+ with open("../data/inference_instructions/hotbot_instructions_main.txt") as h:
113
+ HOTBOT_INSTRUCT = h.read()
114
+
115
+ # CoolBot Prompt
116
+ with open("../data/prompts/coolbot_prompt_main.txt") as c:
117
+ COOLBOT_PROMPT = c.read()
118
+ # Instructions
119
+ with open("../data/inference_instructions/coolbot_instructions_main.txt") as c:
120
+ COOLBOT_INSTRUCT = c.read()
121
+
122
+ # Randomly select fruits to use for display names
123
+ def choose_names(n):
124
+ # Return n unique random fruit names
125
+ return random.sample(FRUIT_NAMES, n)
126
+
127
+ # Send initial watermelon post
128
+ def send_initial_post(room_id, delay):
129
+ # Wait 1 second before sending
130
+ time.sleep(delay)
131
+ # Get the inital post for this topic
132
+ room_doc = rooms_collection.find_one({"_id": room_id})
133
+ topic_title = room_doc["topic"]
134
+ topic_info = next((t for t in TOPICS_LIST if t["title"] == topic_title), None)
135
+ if not topic_info:
136
+ return
137
+ initialPost = topic_info["post"]
138
+ # Store the initial post in the database
139
+ db_msg = {
140
+ "sender": "watermelon",
141
+ "message": initialPost,
142
+ "timestamp": datetime.utcnow()
143
+ }
144
+ rooms_collection.update_one(
145
+ {"_id": room_id},
146
+ {"$push": {"messages": db_msg}}
147
+ )
148
+ # Send to the client (must use emit when in background thread)
149
+ socketio.emit("message", {"sender": "watermelon", "message": initialPost}, to=room_id)
150
+
151
+ #send to the bots
152
+ socketio.start_background_task(ask_bot_round, room_id)
153
+
154
+ # Send message that a bot joined the room
155
+ def send_bot_joined(room_id, bot_name, delay):
156
+ # Wait 1 second before sending
157
+ time.sleep(delay)
158
+ socketio.emit("message", {"sender": "", "message": f"{bot_name} has entered the chat"}, to=room_id)
159
+
160
+ # Trigger a round of bot calls if user has been inactive for a while
161
+ def user_inactivity_tracker(room_id, timeout_seconds=180):
162
+ print(f"Started user inactivity tracker for Room ID#{room_id}")
163
+ while True:
164
+ room_doc = rooms_collection.find_one({"_id": room_id})
165
+ # Stop if this room's chat has ended
166
+ if not room_doc or room_doc.get("ended", False):
167
+ print(f"User inactivity tracker stopping for Room ID#{room_id}")
168
+ return
169
+ lastTime = room_doc.get("last_activity")
170
+ if lastTime:
171
+ if datetime.utcnow() - lastTime > timedelta(seconds=timeout_seconds):
172
+ print(f"User has been inactive in Room ID#{room_id} - triggering new round of bot calls.")
173
+ socketio.start_background_task(ask_bot_round, room_id)
174
+ # Prevent multiple bot call triggers due to inactivity
175
+ rooms_collection.update_one(
176
+ {"_id": room_id},
177
+ {"$set": {"last_activity": datetime.utcnow()}}
178
+ )
179
+ time.sleep(5) # re-check inactivity every 5s
180
+
181
+ def let_to_name(room_id, text):
182
+ named_response = str(text)
183
+ letters = [aliases[name] for name in (FRUIT_NAMES + ["watermelon"])] # makes a copy, rather than directly modifying
184
+ for letter in set(re.findall(r"\b[A-Z]\b", named_response)):
185
+ if letter in letters:
186
+ named_response = re.sub(r"\b" + letter + r"\b", reverse_aliases[letter], named_response)
187
+ return named_response
188
+
189
+ def name_to_let(room_id, text):
190
+ named_response = str(text)
191
+ names = FRUIT_NAMES + ["watermelon"] # makes a copy, rather than directly modifying
192
+ for name in names:
193
+ if name in text:
194
+ text = re.sub(r"\b" + name + r"\b", aliases[name], text, flags=re.I)
195
+ return text
196
+
197
+ def replace_semicolons(text, probability=0.80):
198
+ modified_text = []
199
+ for char in text:
200
+ if char == ';' and random.random() <= probability:
201
+ modified_text.append(',')
202
+ else:
203
+ modified_text.append(char)
204
+ return ''.join(modified_text)
205
+
206
+ def get_response_delay(response):
207
+ baseDelay = 1 # standard delay for thinking
208
+ randFactor = random.uniform(2, 12.)
209
+ perCharacterDelay = 0.12
210
+ # was .25 -> average speed: 3.33 characters/second = 0.3
211
+ maxDelay = 150 # maximum cap of 2.5 minutes (so the bots don't take too long)
212
+ # Add total delay
213
+ totalDelay = baseDelay + perCharacterDelay * len(response) + randFactor
214
+ return min(totalDelay, maxDelay)
215
+
216
+ # Ask a bot for its response, store in DB, and send to client
217
+ # Returns true if the bot passed
218
+ def ask_bot(room_id, bot, bot_display_name, initial_prompt, instruct_prompt):
219
+ # Prevents crashing if bot model did not load
220
+ if bot is None:
221
+ return False
222
+ # Get the full chat room history
223
+ room_doc = rooms_collection.find_one({"_id": room_id})
224
+ # Do not proceed if the chat has ended
225
+ if not room_doc or room_doc.get("ended", False):
226
+ return False
227
+ history = room_doc["messages"]
228
+ # Build the LLM prompt
229
+ prompt = re.sub(r"<RE>", aliases[bot_display_name], initial_prompt)
230
+ context = list() #get the context sent to bot for duplicate_check
231
+ for message in history[-CHAT_CONTEXT:]:
232
+ prompt += f"{aliases[message['sender']]}: {message['message']}\n"
233
+ context.append(message['message'])
234
+
235
+ prompt = name_to_let(room_id, prompt) #sub fruit names to letters to give to bots
236
+
237
+ print("\n")
238
+ print("=================================prompt")
239
+ print(prompt)
240
+
241
+ # Get the bot's response
242
+ try:
243
+ response = vertex_client.models.generate_content(
244
+ model = bot,
245
+ contents = prompt,
246
+ config=GenerateContentConfig(
247
+ system_instruction = [instruct_prompt]
248
+ ),
249
+ )
250
+ parsed_response = response.candidates[0].content.parts[0].text.strip()
251
+ except Exception as e:
252
+ print("Error in bot response: ", e)
253
+ print("Treating this bot's response as a pass.")
254
+ # Do not store/send messages if the chat has ended
255
+ room_doc = rooms_collection.find_one({"_id": room_id})
256
+ if not room_doc or room_doc.get("ended", False):
257
+ return False
258
+ # Store the error response in the database
259
+ bot_message = {
260
+ "sender": bot_display_name,
261
+ "message": "ERROR in bot response - treated as a (pass)",
262
+ "timestamp": datetime.utcnow()
263
+ }
264
+ rooms_collection.update_one(
265
+ {"_id": room_id},
266
+ {"$push": {"messages": bot_message}}
267
+ )
268
+ return True
269
+
270
+ #remove bot formatting like <i></i> <b></b> that will render on the page
271
+ parsed_response = re.sub(r"<([a-zA-Z]+)>(?=.*</\1>)", "", parsed_response)
272
+ parsed_response = re.sub(r"</([a-zA-Z]+)>", "", parsed_response)
273
+ #fix any escaped \\n --> \n so they are actual newlines
274
+ parsed_response = re.sub(r"\\n", "\n", parsed_response).strip()
275
+ #remove bot heading ("C: ...")
276
+ if re.search(r"\b" + aliases[bot_display_name] + r"\b:",
277
+ parsed_response):
278
+ parsed_response = re.sub(r"\b"
279
+ + aliases[bot_display_name]
280
+ + r"\b:\s?", '', parsed_response)
281
+
282
+ # Check for if the bot passed (i.e. response = "(pass)")
283
+ if ("(pass)" in parsed_response) or (parsed_response == ""):
284
+ # Do not store/send messages if the chat has ended
285
+ room_doc = rooms_collection.find_one({"_id": room_id})
286
+ if not room_doc or room_doc.get("ended", False):
287
+ return False
288
+ # Store the pass in the database
289
+ bot_message = {
290
+ "sender": bot_display_name,
291
+ "message": parsed_response,
292
+ "timestamp": datetime.utcnow()
293
+ }
294
+ rooms_collection.update_one(
295
+ {"_id": room_id},
296
+ {"$push": {"messages": bot_message}}
297
+ )
298
+
299
+ print("PASSED")
300
+ return True # a pass is still recorded in the database, but not sent to the client
301
+
302
+ #remove encapsulating quotes
303
+ no_quotes = remove_quotes(parsed_response)
304
+ #humanize the response (remove obvious AI formatting styles)
305
+ humanized_response = humanize(no_quotes)
306
+ #replace most semicolons
307
+ less_semicolons_response = replace_semicolons(humanized_response)
308
+ #corrupt the response (add some typos and misspellings)
309
+ corrupted_response = corrupt(less_semicolons_response)
310
+ #remove weird chars
311
+ no_weird_chars = remove_weird_characters(corrupted_response)
312
+ #sub letters for names, so if the bot addressed A -> Apple
313
+ named_response = let_to_name(room_id, no_weird_chars)
314
+
315
+ #check that there are no reccent duplicate messages
316
+ if len(named_response) > DUP_LEN and duplicate_check(named_response, context):
317
+ print("****DUPLICATE MESSAGE DETECTED")
318
+ print("Treating this bot's response as a pass.")
319
+ # Do not store/send messages if the chat has ended
320
+ room_doc = rooms_collection.find_one({"_id": room_id})
321
+ if not room_doc or room_doc.get("ended", False):
322
+ return False
323
+ # Store the error response in the database
324
+ bot_message = {
325
+ "sender": bot_display_name,
326
+ "message": f"DUPLICATE message detected - treated as a (pass) : {named_response}",
327
+ "timestamp": datetime.utcnow()
328
+ }
329
+ rooms_collection.update_one(
330
+ {"_id": room_id},
331
+ {"$push": {"messages": bot_message}}
332
+ )
333
+ return False
334
+
335
+
336
+ print("\n")
337
+ print("=================================response")
338
+ print(corrupted_response)
339
+
340
+ # Add latency/wait time for bot responses
341
+ delay = get_response_delay(named_response);
342
+ print(delay)
343
+ time.sleep(delay)
344
+
345
+ # Do not store/send messages if the chat has ended
346
+ room_doc = rooms_collection.find_one({"_id": room_id})
347
+ if not room_doc or room_doc.get("ended", False):
348
+ return False
349
+
350
+ # Store the response in the database
351
+ bot_message = {
352
+ "sender": bot_display_name,
353
+ "message": named_response, #save fruits in db so page reload shows proper names
354
+ "timestamp": datetime.utcnow()
355
+ }
356
+ rooms_collection.update_one(
357
+ {"_id": room_id},
358
+ {"$push": {"messages": bot_message}}
359
+ )
360
+
361
+ # Send the bot's response to the client
362
+ socketio.emit("message", {"sender": bot_display_name, "message": named_response}, to=room_id)
363
+ return False
364
+
365
+ def ask_bot_round(room_id):
366
+ while True:
367
+ room_doc = rooms_collection.find_one({"_id": room_id})
368
+ if not room_doc or room_doc.get("ended", False):
369
+ return
370
+
371
+ with concurrent.futures.ThreadPoolExecutor() as exec:
372
+ futures = [
373
+ exec.submit(ask_bot, room_id, frobot, room_doc["FroBot_name"], FROBOT_PROMPT, FROBOT_INSTRUCT),
374
+ exec.submit(ask_bot, room_id, hotbot, room_doc["HotBot_name"], HOTBOT_PROMPT, FROBOT_INSTRUCT),
375
+ exec.submit(ask_bot, room_id, coolbot, room_doc["CoolBot_name"], COOLBOT_PROMPT, FROBOT_INSTRUCT)
376
+ ]
377
+ results = [f.result() for f in futures]
378
+
379
+ print("Raw pass check results: ", results)
380
+ if not all(results):
381
+ print("At least one bot responded. Not re-prompting.\n")
382
+ return # at least one bot responded
383
+
384
+ # All bots passed - reprompt
385
+ print("All bots passed. Re-prompting for responses.\n")
386
+ time.sleep(2) # prevents CPU thrashing & spamming
387
+
388
+ # Build the routes
389
+ #disabled landing
390
+ #@app.route('/', methods=["GET"])
391
+ def landing():
392
+ return render_template('landing.html')
393
+ #disabled waiting
394
+ #@app.route('/wait', methods=["GET"])
395
+ def waiting():
396
+ return render_template('waiting.html')
397
+ #changed /chat -> /
398
+ @app.route('/', methods=["GET", "POST"])
399
+ def home():
400
+ #session.clear()
401
+
402
+ #get PROLIFIC_PID from qualtrics
403
+ #test if user_id in session
404
+ prolific_pid = request.args.get("PROLIFIC_PID") or session.get('user_id') or ''
405
+
406
+ if request.method == "POST":
407
+ user_id = request.form.get('name')
408
+ if not user_id:
409
+ return render_template('home.html', error="Prolific ID is required", prolific_pid=prolific_pid)
410
+ session['user_id'] = user_id
411
+ return redirect(url_for('topics'))
412
+ else:
413
+ return render_template('home.html',prolific_pid=prolific_pid)
414
+
415
+ @app.route('/topics', methods=["GET", "POST"])
416
+ def topics():
417
+ user_id = session.get('user_id')
418
+ if not user_id:
419
+ return redirect(url_for('home'))
420
+
421
+ exists = db.rooms.find_one({"user_id":user_id})
422
+ if exists:
423
+ #set session vars for room()
424
+ session['room'] = exists['_id']
425
+ session['display_name'] = exists['user_name']
426
+ return redirect(url_for('room'))
427
+
428
+ #don't let browser cache this page
429
+ resp = make_response( render_template('topics.html', topics=TOPICS_LIST) )
430
+ resp.headers['Cache-Control'] = 'no-store'
431
+ return resp
432
+
433
+ @app.route('/choose', methods=["POST"])
434
+ def choose():
435
+ user_id = session.get('user_id')
436
+ if not user_id:
437
+ return redirect(url_for('home'))
438
+ topic = request.form.get('topic')
439
+ if not topic:
440
+ return redirect(url_for('topics'))
441
+ topic_info = next((t for t in TOPICS_LIST if t["title"] == topic), None)
442
+ if topic_info is None:
443
+ return redirect(url_for('topics'))
444
+ # Get next room id (and add one)
445
+ counter = db.counters.find_one_and_update(
446
+ {"_id": "room_id"},
447
+ {"$inc": {"seq": 1}}, # increment seq by 1
448
+ upsert=True, # create if missing
449
+ return_document=True
450
+ )
451
+ room_id = counter["seq"]
452
+ # Pick fruit display names
453
+ fruit_names = choose_names(4)
454
+ user_name = fruit_names[0]
455
+ frobot_name = fruit_names[1]
456
+ hotbot_name = fruit_names[2]
457
+ coolbot_name = fruit_names[3]
458
+
459
+ # Create the new room in the database
460
+ rooms_collection.insert_one({
461
+ "_id": room_id,
462
+ "topic": topic_info['title'],
463
+ # creation date/time
464
+ "created_at": datetime.utcnow(),
465
+ # user identity
466
+ "user_id": user_id,
467
+ "user_name": user_name,
468
+ # bot names
469
+ "FroBot_name": frobot_name,
470
+ "HotBot_name": hotbot_name,
471
+ "CoolBot_name": coolbot_name,
472
+ # flags needed for handling refreshes
473
+ "initialPostsSent": False,
474
+ "inactivity_tracker_started": False,
475
+ # empty message history
476
+ "messages": [],
477
+ # last time user sent a message
478
+ "last_activity": datetime.utcnow(),
479
+ # flag for if the user aborts
480
+ "aborted": False,
481
+ # flag for if the chat has ended
482
+ "ended": False,
483
+ "ended_at": None
484
+ })
485
+
486
+ session['room'] = room_id
487
+ session['display_name'] = user_name
488
+ return redirect(url_for('room'))
489
+
490
+ @app.route('/room')
491
+ def room():
492
+ room_id = session.get('room')
493
+ display_name = session.get('display_name')
494
+ if not room_id or not display_name:
495
+ return redirect(url_for('home'))
496
+ room_doc = rooms_collection.find_one({"_id": room_id})
497
+ if not room_doc:
498
+ return redirect(url_for('home'))
499
+ topic = room_doc["topic"]
500
+ topic_info = next((t for t in TOPICS_LIST if t["title"] == topic), None)
501
+ if topic_info is None:
502
+ return redirect(url_for('topics'))
503
+ nonpass_messages = [
504
+ m for m in room_doc["messages"]
505
+ if m.get("message", "").strip() != "(pass)"
506
+ ]
507
+ return render_template("room.html", room=room_id, topic_info=topic_info, user=display_name, messages=nonpass_messages, FroBot_name=room_doc["FroBot_name"], HotBot_name=room_doc["HotBot_name"], CoolBot_name=room_doc["CoolBot_name"], ended=room_doc["ended"])
508
+
509
+ @app.route("/abort", methods=["POST"])
510
+ def abort_room():
511
+ room_id = session.get("room")
512
+ if not room_id:
513
+ return ("Error: No room in session.", 400)
514
+ rooms_collection.update_one(
515
+ {"_id": room_id},
516
+ {"$set": {"aborted": True}}
517
+ )
518
+ return ("OK", 200)
519
+
520
+ @app.route("/post_survey", methods=["POST", "GET"])
521
+ def post_survey():
522
+ user_id = session.get('user_id')
523
+ if not user_id:
524
+ return render_template('home.html', error="Enter your Prolific ID.")
525
+ info = db.rooms.find_one({"user_id":user_id}, {'FroBot_name':1,
526
+ 'HotBot_name':1,
527
+ 'CoolBot_name':1} )
528
+ if not info:
529
+ return render_template('home.html', error="Enter your ID.")
530
+
531
+ # Store in the DB that this chat has been ended
532
+ db.rooms.update_one(
533
+ {"user_id":user_id},
534
+ {"$set": {"ended": True, "ended_at": datetime.utcnow()}}
535
+ )
536
+
537
+ CName = info['CoolBot_name']
538
+ FName = info['FroBot_name']
539
+ HName = info['HotBot_name']
540
+
541
+ SURVEY_2_LINK = f"https://umw.qualtrics.com/jfe/form/SV_eIIbPlJ2D9k4zKC?PROLIFIC_PID={user_id}&CName={CName}&FName={FName}&HName={HName}"
542
+
543
+ return redirect(SURVEY_2_LINK)
544
+
545
+ # Build the SocketIO event handlers
546
+
547
+ @socketio.on('connect')
548
+ def handle_connect():
549
+ name = session.get('display_name')
550
+ room = session.get('room')
551
+ if not name or not room:
552
+ return
553
+ room_doc = rooms_collection.find_one({"_id": room})
554
+ if not room_doc:
555
+ return
556
+ join_room(room)
557
+ if (room_doc.get("initialPostsSent", False)):
558
+ return
559
+ # Send the message that "watermelon" has already joined the chat
560
+ send({
561
+ "sender": "",
562
+ "message": "watermelon has entered the chat"
563
+ }, to=room)
564
+ # Send the message that this user has joined the chat
565
+ send({
566
+ "sender": "",
567
+ "message": f"{name} has entered the chat"
568
+ }, to=room)
569
+ # Start background tasks for the bots to join after a short delay
570
+ socketio.start_background_task(send_bot_joined, room, room_doc['CoolBot_name'], 3)
571
+ socketio.start_background_task(send_bot_joined, room, room_doc['FroBot_name'], 7)
572
+ socketio.start_background_task(send_bot_joined, room, room_doc['HotBot_name'], 13)
573
+ # Start background task to send the initial watermelon post after a short delay
574
+ socketio.start_background_task(send_initial_post, room, 10)
575
+ rooms_collection.update_one(
576
+ {"_id": room},
577
+ {"$set": {"initialPostsSent": True}}
578
+ )
579
+ # Start user inactivity tracker
580
+ if not room_doc.get("inactivity_tracker_started", False):
581
+ rooms_collection.update_one(
582
+ {"_id": room},
583
+ {
584
+ "$set": {
585
+ "inactivity_tracker_started": True,
586
+ "last_activity": datetime.utcnow()
587
+ }
588
+ }
589
+ )
590
+ socketio.start_background_task(user_inactivity_tracker, room)
591
+
592
+ @socketio.on('message')
593
+ def handle_message(payload):
594
+ room = session.get('room')
595
+ name = session.get('display_name')
596
+ if not room or not name:
597
+ return
598
+
599
+ # Stop message processing if the chat has ended
600
+ room_doc = rooms_collection.find_one({"_id": room})
601
+ if not room_doc or room_doc.get("ended", False):
602
+ return
603
+
604
+ text = payload.get("message", "").strip()
605
+ if not text:
606
+ return # ignore empty messages
607
+
608
+ # Client-visible message (no datetime)
609
+ client_message = {
610
+ "sender": name,
611
+ "message": text
612
+ }
613
+ # Database-only message (with datetime)
614
+ db_message = {
615
+ "sender": name,
616
+ "message": text,
617
+ "timestamp": datetime.utcnow()
618
+ }
619
+ # Store the full version in the database
620
+ rooms_collection.update_one(
621
+ {"_id": room},
622
+ {
623
+ "$push": {"messages": db_message},
624
+ "$set": {"last_activity": datetime.utcnow()}
625
+ }
626
+ )
627
+ # Send only the client version (no datetime)
628
+ send(client_message, to=room)
629
+
630
+ # Ask each bot for a response
631
+ socketio.start_background_task(ask_bot_round, room)
632
+
633
+ @socketio.on('disconnect')
634
+ def handle_disconnect():
635
+ room = session.get("room")
636
+ name = session.get("display_name")
637
+
638
+ if room:
639
+ send({
640
+ "sender": "",
641
+ "message": f"{name} has left the chat"
642
+ }, to=room)
643
+ leave_room(room)
644
+
645
+
646
+ if __name__ == "__main__":
647
+ print("Async mode:", socketio.async_mode)
648
+ socketio.run(app, host='0.0.0.0', port=5000, debug=True)
649
+