mike boone commited on
Commit
ece9b51
·
1 Parent(s): 98056e5

feat: add temporary-password onboarding flow

Browse files
chat_interface.py CHANGED
@@ -205,6 +205,29 @@ def require_authenticated_email(request: gr.Request = None, user_email: str = No
205
  )
206
 
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  def build_initial_chat_message(company: str, use_case: str) -> str:
209
  """Build the pre-filled chat message from current settings."""
210
  if company and use_case:
@@ -264,6 +287,23 @@ class ChatDemoInterface:
264
  """Resolve and cache effective user identity for settings access."""
265
  self.user_email = require_authenticated_email(user_email=self.user_email)
266
  return self.user_email
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  def load_default_settings(self):
269
  """Load settings from Supabase or defaults"""
@@ -408,6 +448,12 @@ I'll research a company, build a Snowflake schema, generate realistic data, and
408
  # Add user message to history
409
  chat_history.append((message, None))
410
 
 
 
 
 
 
 
411
  # If data_adjuster_url is saved in settings and we're at init, inject it as the message
412
  # so the user lands directly in Data Adjuster without having to paste the URL manually
413
  da_url = self.settings.get('data_adjuster_url', '').strip()
@@ -1886,6 +1932,12 @@ To change settings, use:
1886
  model=resolved_model,
1887
  model_setting=model_setting,
1888
  )
 
 
 
 
 
 
1889
  _t = _slog.log_start("research") if _slog else None
1890
 
1891
  print(f"\n\n[CACHE DEBUG] === run_research_streaming called ===")
@@ -4913,8 +4965,8 @@ def create_chat_interface():
4913
  with gr.Column(scale=2):
4914
  gr.Markdown("#### Current Users")
4915
  user_list_display = gr.Dataframe(
4916
- headers=["Email", "Display Name", "Admin", "Active", "Last Login EST"],
4917
- datatype=["str", "str", "bool", "bool", "str"],
4918
  interactive=False,
4919
  label="Users"
4920
  )
@@ -4923,10 +4975,21 @@ def create_chat_interface():
4923
  with gr.Column(scale=1):
4924
  gr.Markdown("#### Add New User")
4925
  new_user_email = gr.Textbox(label="Email", placeholder="user@company.com")
4926
- new_user_password = gr.Textbox(label="Password", type="password")
 
 
 
 
4927
  new_user_display = gr.Textbox(label="Display Name", placeholder="Jane Doe")
4928
  new_user_admin = gr.Checkbox(label="Admin?", value=False)
4929
  add_user_btn = gr.Button("➕ Add User", variant="primary")
 
 
 
 
 
 
 
4930
 
4931
  gr.Markdown("---")
4932
  gr.Markdown("#### User Actions")
@@ -4965,26 +5028,50 @@ def create_chat_interface():
4965
  u.get('display_name', ''),
4966
  u.get('is_admin', False),
4967
  u.get('is_active', True),
 
4968
  last_login_str
4969
  ])
4970
  return rows
4971
  except Exception as e:
4972
- return [[f"Error: {e}", "", False, False, ""]]
4973
 
4974
- def add_user_handler(email, password, display_name, is_admin):
4975
  """Add a new user."""
4976
- if not email or not password:
4977
- return load_user_list(), "Email and password are required."
4978
  try:
4979
  from supabase_client import UserManager
4980
  um = UserManager()
4981
- success = um.add_user(email, password, display_name, is_admin)
 
 
 
 
 
 
 
4982
  if success:
4983
- return load_user_list(), f"User {email} added successfully."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4984
  else:
4985
- return load_user_list(), f"Failed to add user {email}."
4986
  except Exception as e:
4987
- return load_user_list(), f"Error: {e}"
4988
 
4989
  def deactivate_handler(email):
4990
  if not email:
@@ -5014,13 +5101,13 @@ def create_chat_interface():
5014
  try:
5015
  from supabase_client import UserManager
5016
  um = UserManager()
5017
- um.reset_password(email, new_pw)
5018
  return f"Password reset for {email}."
5019
  except Exception as e:
5020
  return f"Error: {e}"
5021
 
5022
  refresh_users_btn.click(fn=load_user_list, inputs=[], outputs=[user_list_display])
5023
- add_user_btn.click(fn=add_user_handler, inputs=[new_user_email, new_user_password, new_user_display, new_user_admin], outputs=[user_list_display, user_mgmt_status])
5024
  deactivate_btn.click(fn=deactivate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
5025
  activate_btn.click(fn=activate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
5026
  reset_pw_btn.click(fn=reset_password_handler, inputs=[action_email, new_password], outputs=[user_mgmt_status])
@@ -6581,6 +6668,7 @@ def create_settings_tab():
6581
  if not um.authenticate(user_email, current):
6582
  return "❌ Current password is incorrect."
6583
  um.reset_password(user_email, new_pw)
 
6584
  return "✅ Password changed successfully. Use your new password next time you sign in."
6585
  except Exception as e:
6586
  return f"❌ Error: {e}"
 
205
  )
206
 
207
 
208
+ def resolve_app_url_for_invite(request: gr.Request = None) -> str:
209
+ """Resolve the public app URL for copy/paste onboarding invites."""
210
+ configured_url = (
211
+ os.getenv("DEMOPREP_APP_URL", "").strip()
212
+ or os.getenv("PUBLIC_APP_URL", "").strip()
213
+ or os.getenv("SPACE_HOST", "").strip()
214
+ )
215
+ if configured_url:
216
+ if configured_url.startswith("http"):
217
+ return configured_url.rstrip("/")
218
+ return f"https://{configured_url.strip('/')}"
219
+
220
+ try:
221
+ headers = getattr(request, "headers", {}) if request else {}
222
+ referer = headers.get("referer") or headers.get("origin") or ""
223
+ if referer:
224
+ return referer.split("?")[0].rstrip("/")
225
+ except Exception:
226
+ pass
227
+
228
+ return "https://thoughtspot-dp-test-demoprep.hf.space"
229
+
230
+
231
  def build_initial_chat_message(company: str, use_case: str) -> str:
232
  """Build the pre-filled chat message from current settings."""
233
  if company and use_case:
 
287
  """Resolve and cache effective user identity for settings access."""
288
  self.user_email = require_authenticated_email(user_email=self.user_email)
289
  return self.user_email
290
+
291
+ def _temporary_password_block_message(self) -> str:
292
+ """Return a blocking message when a temp-password user tries to run the app."""
293
+ try:
294
+ from supabase_client import UserManager
295
+ user_email = self._get_effective_user_email()
296
+ um = UserManager()
297
+ if um.enabled and um.must_change_password(user_email):
298
+ return (
299
+ "🔒 **Password change required**\n\n"
300
+ "You are signed in with a temporary password. "
301
+ "Open **Settings → Change Password**, set your own password, "
302
+ "then come back and run DemoPrep."
303
+ )
304
+ except Exception as e:
305
+ print(f"[Auth] Unable to check temporary-password status: {e}")
306
+ return ""
307
 
308
  def load_default_settings(self):
309
  """Load settings from Supabase or defaults"""
 
448
  # Add user message to history
449
  chat_history.append((message, None))
450
 
451
+ password_block = self._temporary_password_block_message()
452
+ if password_block and current_stage in {'initialization', 'awaiting_context'}:
453
+ chat_history[-1] = (message, password_block)
454
+ yield chat_history, current_stage, current_model, company, use_case, ""
455
+ return
456
+
457
  # If data_adjuster_url is saved in settings and we're at init, inject it as the message
458
  # so the user lands directly in Data Adjuster without having to paste the URL manually
459
  da_url = self.settings.get('data_adjuster_url', '').strip()
 
1932
  model=resolved_model,
1933
  model_setting=model_setting,
1934
  )
1935
+ password_block = self._temporary_password_block_message()
1936
+ if password_block:
1937
+ if _slog:
1938
+ _slog.log("auth", "temporary password blocked pipeline start")
1939
+ yield password_block
1940
+ return
1941
  _t = _slog.log_start("research") if _slog else None
1942
 
1943
  print(f"\n\n[CACHE DEBUG] === run_research_streaming called ===")
 
4965
  with gr.Column(scale=2):
4966
  gr.Markdown("#### Current Users")
4967
  user_list_display = gr.Dataframe(
4968
+ headers=["Email", "Display Name", "Admin", "Active", "Must Change PW", "Last Login EST"],
4969
+ datatype=["str", "str", "bool", "bool", "bool", "str"],
4970
  interactive=False,
4971
  label="Users"
4972
  )
 
4975
  with gr.Column(scale=1):
4976
  gr.Markdown("#### Add New User")
4977
  new_user_email = gr.Textbox(label="Email", placeholder="user@company.com")
4978
+ new_user_password = gr.Textbox(
4979
+ label="Temporary Password (optional)",
4980
+ type="password",
4981
+ placeholder="Leave blank to generate one"
4982
+ )
4983
  new_user_display = gr.Textbox(label="Display Name", placeholder="Jane Doe")
4984
  new_user_admin = gr.Checkbox(label="Admin?", value=False)
4985
  add_user_btn = gr.Button("➕ Add User", variant="primary")
4986
+ invite_message = gr.Textbox(
4987
+ label="Slack invite message",
4988
+ lines=9,
4989
+ interactive=True,
4990
+ show_copy_button=True,
4991
+ placeholder="Add a user to generate the message to send in Slack."
4992
+ )
4993
 
4994
  gr.Markdown("---")
4995
  gr.Markdown("#### User Actions")
 
5028
  u.get('display_name', ''),
5029
  u.get('is_admin', False),
5030
  u.get('is_active', True),
5031
+ u.get('must_change_password', False),
5032
  last_login_str
5033
  ])
5034
  return rows
5035
  except Exception as e:
5036
+ return [[f"Error: {e}", "", False, False, False, ""]]
5037
 
5038
+ def add_user_handler(email, password, display_name, is_admin, request: gr.Request = None):
5039
  """Add a new user."""
5040
+ if not email:
5041
+ return load_user_list(), "Email is required.", ""
5042
  try:
5043
  from supabase_client import UserManager
5044
  um = UserManager()
5045
+ temp_password = password or um.generate_temp_password()
5046
+ success = um.add_user(
5047
+ email,
5048
+ temp_password,
5049
+ display_name,
5050
+ is_admin,
5051
+ must_change_password=True,
5052
+ )
5053
  if success:
5054
+ clean_email = email.lower().strip()
5055
+ display = (display_name or clean_email.split("@")[0]).strip()
5056
+ app_url = resolve_app_url_for_invite(request)
5057
+ invite = (
5058
+ f"Hi {display} - you now have access to DemoPrep.\n\n"
5059
+ f"App: {app_url}\n"
5060
+ f"Username: {clean_email}\n"
5061
+ f"Temporary password: {temp_password}\n\n"
5062
+ "Please sign in and immediately change your password in "
5063
+ "Settings -> Change Password before running demos."
5064
+ )
5065
+ if not um._supports_must_change_password():
5066
+ invite += (
5067
+ "\n\nAdmin note: temporary-password enforcement is not active "
5068
+ "until the demoprep_users.must_change_password migration is applied."
5069
+ )
5070
+ return load_user_list(), f"User {clean_email} added. Slack invite generated below.", invite
5071
  else:
5072
+ return load_user_list(), f"Failed to add user {email}.", ""
5073
  except Exception as e:
5074
+ return load_user_list(), f"Error: {e}", ""
5075
 
5076
  def deactivate_handler(email):
5077
  if not email:
 
5101
  try:
5102
  from supabase_client import UserManager
5103
  um = UserManager()
5104
+ um.reset_password(email, new_pw, must_change_password=True)
5105
  return f"Password reset for {email}."
5106
  except Exception as e:
5107
  return f"Error: {e}"
5108
 
5109
  refresh_users_btn.click(fn=load_user_list, inputs=[], outputs=[user_list_display])
5110
+ add_user_btn.click(fn=add_user_handler, inputs=[new_user_email, new_user_password, new_user_display, new_user_admin], outputs=[user_list_display, user_mgmt_status, invite_message])
5111
  deactivate_btn.click(fn=deactivate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
5112
  activate_btn.click(fn=activate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
5113
  reset_pw_btn.click(fn=reset_password_handler, inputs=[action_email, new_password], outputs=[user_mgmt_status])
 
6668
  if not um.authenticate(user_email, current):
6669
  return "❌ Current password is incorrect."
6670
  um.reset_password(user_email, new_pw)
6671
+ um.clear_must_change_password(user_email)
6672
  return "✅ Password changed successfully. Use your new password next time you sign in."
6673
  except Exception as e:
6674
  return f"❌ Error: {e}"
docs/onboarding_auth_migration.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Onboarding Auth Migration
2
+
3
+ Run this once against the Supabase database that backs DemoPrep auth before onboarding users with temporary passwords:
4
+
5
+ ```sql
6
+ alter table demoprep_users
7
+ add column if not exists must_change_password boolean not null default false;
8
+ ```
9
+
10
+ Without this column, admins can still add users and generate Slack invite text, but the app cannot enforce the first-login password change.
supabase_client.py CHANGED
@@ -18,6 +18,8 @@ Usage:
18
 
19
  import os
20
  import json
 
 
21
  from typing import Dict, List, Optional, Any
22
  from datetime import datetime
23
  from dotenv import load_dotenv
@@ -328,8 +330,11 @@ class UserManager:
328
  is_active BOOLEAN DEFAULT TRUE
329
  created_at TIMESTAMPTZ DEFAULT now()
330
  last_login TIMESTAMPTZ
 
331
  """
332
 
 
 
333
  def __init__(self):
334
  self.client = None
335
  self.enabled = False
@@ -371,6 +376,36 @@ class UserManager:
371
  pw_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
372
  return pw_hash.hex() == expected_hash
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  def authenticate(self, email: str, password: str) -> Optional[Dict]:
375
  """
376
  Authenticate a user by email and password.
@@ -416,6 +451,7 @@ class UserManager:
416
  "display_name": user.get("display_name", ""),
417
  "is_admin": user.get("is_admin", False),
418
  "is_active": user.get("is_active", True),
 
419
  }
420
 
421
  except Exception as e:
@@ -423,7 +459,7 @@ class UserManager:
423
  return None
424
 
425
  def add_user(self, email: str, password: str, display_name: str = "",
426
- is_admin: bool = False) -> bool:
427
  """Add a new user. Returns True if successful."""
428
  if not self.enabled:
429
  return False
@@ -437,6 +473,8 @@ class UserManager:
437
  "is_active": True,
438
  "created_at": datetime.utcnow().isoformat(),
439
  }
 
 
440
  self.client.table("demoprep_users").insert(data).execute()
441
  print(f"UserManager: Added user {email} (admin={is_admin})")
442
  return True
@@ -450,8 +488,11 @@ class UserManager:
450
  return []
451
 
452
  try:
 
 
 
453
  result = self.client.table("demoprep_users") \
454
- .select("email, display_name, is_admin, is_active, created_at, last_login") \
455
  .order("created_at") \
456
  .execute()
457
  return result.data or []
@@ -480,14 +521,17 @@ class UserManager:
480
  print(f"UserManager: Error updating user {email}: {e}")
481
  return False
482
 
483
- def reset_password(self, email: str, new_password: str) -> bool:
484
  """Reset a user's password."""
485
  if not self.enabled:
486
  return False
487
 
488
  try:
 
 
 
489
  self.client.table("demoprep_users") \
490
- .update({"password_hash": self._hash_password(new_password)}) \
491
  .eq("email", email.lower().strip()) \
492
  .execute()
493
  return True
@@ -495,6 +539,35 @@ class UserManager:
495
  print(f"UserManager: Error resetting password for {email}: {e}")
496
  return False
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  def deactivate_user(self, email: str) -> bool:
499
  """Deactivate a user (soft delete)."""
500
  return self.update_user(email, is_active=False)
@@ -842,4 +915,4 @@ if __name__ == "__main__":
842
  settings.delete_setting(test_email, "test_setting")
843
  print("\n✅ All tests completed!")
844
  else:
845
- print("❌ Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env file.")
 
18
 
19
  import os
20
  import json
21
+ import string
22
+ import secrets
23
  from typing import Dict, List, Optional, Any
24
  from datetime import datetime
25
  from dotenv import load_dotenv
 
330
  is_active BOOLEAN DEFAULT TRUE
331
  created_at TIMESTAMPTZ DEFAULT now()
332
  last_login TIMESTAMPTZ
333
+ must_change_password BOOLEAN DEFAULT FALSE
334
  """
335
 
336
+ _must_change_password_supported: Optional[bool] = None
337
+
338
  def __init__(self):
339
  self.client = None
340
  self.enabled = False
 
376
  pw_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
377
  return pw_hash.hex() == expected_hash
378
 
379
+ def _supports_must_change_password(self) -> bool:
380
+ """Return whether demoprep_users has the onboarding password flag."""
381
+ if not self.enabled:
382
+ return False
383
+ if UserManager._must_change_password_supported is not None:
384
+ return UserManager._must_change_password_supported
385
+
386
+ try:
387
+ self.client.table("demoprep_users") \
388
+ .select("must_change_password") \
389
+ .limit(1) \
390
+ .execute()
391
+ UserManager._must_change_password_supported = True
392
+ except Exception:
393
+ UserManager._must_change_password_supported = False
394
+ return UserManager._must_change_password_supported
395
+
396
+ def generate_temp_password(self, length: int = 16) -> str:
397
+ """Generate a Slack-friendly temporary password."""
398
+ alphabet = string.ascii_letters + string.digits + "!@#$%&*?"
399
+ while True:
400
+ password = ''.join(secrets.choice(alphabet) for _ in range(length))
401
+ if (
402
+ any(c.islower() for c in password)
403
+ and any(c.isupper() for c in password)
404
+ and any(c.isdigit() for c in password)
405
+ and any(c in "!@#$%&*?" for c in password)
406
+ ):
407
+ return password
408
+
409
  def authenticate(self, email: str, password: str) -> Optional[Dict]:
410
  """
411
  Authenticate a user by email and password.
 
451
  "display_name": user.get("display_name", ""),
452
  "is_admin": user.get("is_admin", False),
453
  "is_active": user.get("is_active", True),
454
+ "must_change_password": user.get("must_change_password", False),
455
  }
456
 
457
  except Exception as e:
 
459
  return None
460
 
461
  def add_user(self, email: str, password: str, display_name: str = "",
462
+ is_admin: bool = False, must_change_password: bool = False) -> bool:
463
  """Add a new user. Returns True if successful."""
464
  if not self.enabled:
465
  return False
 
473
  "is_active": True,
474
  "created_at": datetime.utcnow().isoformat(),
475
  }
476
+ if self._supports_must_change_password():
477
+ data["must_change_password"] = must_change_password
478
  self.client.table("demoprep_users").insert(data).execute()
479
  print(f"UserManager: Added user {email} (admin={is_admin})")
480
  return True
 
488
  return []
489
 
490
  try:
491
+ fields = "email, display_name, is_admin, is_active, created_at, last_login"
492
+ if self._supports_must_change_password():
493
+ fields += ", must_change_password"
494
  result = self.client.table("demoprep_users") \
495
+ .select(fields) \
496
  .order("created_at") \
497
  .execute()
498
  return result.data or []
 
521
  print(f"UserManager: Error updating user {email}: {e}")
522
  return False
523
 
524
+ def reset_password(self, email: str, new_password: str, must_change_password: Optional[bool] = None) -> bool:
525
  """Reset a user's password."""
526
  if not self.enabled:
527
  return False
528
 
529
  try:
530
+ update_data = {"password_hash": self._hash_password(new_password)}
531
+ if must_change_password is not None and self._supports_must_change_password():
532
+ update_data["must_change_password"] = must_change_password
533
  self.client.table("demoprep_users") \
534
+ .update(update_data) \
535
  .eq("email", email.lower().strip()) \
536
  .execute()
537
  return True
 
539
  print(f"UserManager: Error resetting password for {email}: {e}")
540
  return False
541
 
542
+ def must_change_password(self, email: str) -> bool:
543
+ """Check whether a user must change a temporary password."""
544
+ if not self.enabled or not self._supports_must_change_password():
545
+ return False
546
+ try:
547
+ result = self.client.table("demoprep_users") \
548
+ .select("must_change_password") \
549
+ .eq("email", email.lower().strip()) \
550
+ .execute()
551
+ if result.data and len(result.data) > 0:
552
+ return bool(result.data[0].get("must_change_password", False))
553
+ return False
554
+ except Exception:
555
+ return False
556
+
557
+ def clear_must_change_password(self, email: str) -> bool:
558
+ """Mark a user's password as no longer temporary."""
559
+ if not self.enabled or not self._supports_must_change_password():
560
+ return True
561
+ try:
562
+ self.client.table("demoprep_users") \
563
+ .update({"must_change_password": False}) \
564
+ .eq("email", email.lower().strip()) \
565
+ .execute()
566
+ return True
567
+ except Exception as e:
568
+ print(f"UserManager: Error clearing temporary password flag for {email}: {e}")
569
+ return False
570
+
571
  def deactivate_user(self, email: str) -> bool:
572
  """Deactivate a user (soft delete)."""
573
  return self.update_user(email, is_active=False)
 
915
  settings.delete_setting(test_email, "test_setting")
916
  print("\n✅ All tests completed!")
917
  else:
918
+ print("❌ Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env file.")
tests/e2e_quality.py CHANGED
@@ -1410,11 +1410,14 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
1410
  last_change_time = time.time()
1411
  STUCK_THRESHOLD = 20 * 60 # 20 min with no stage change → bail early
1412
  PIPELINE_ERROR_INDICATORS = [
 
1413
  "pipeline has been interrupted",
1414
  "An unexpected error occurred",
1415
  "Population failed",
1416
  "Pipeline failed",
1417
  "Something went wrong during the pipeline",
 
 
1418
  ]
1419
  while time.time() - start < timeout_sec:
1420
  time.sleep(poll_interval)
 
1410
  last_change_time = time.time()
1411
  STUCK_THRESHOLD = 20 * 60 # 20 min with no stage change → bail early
1412
  PIPELINE_ERROR_INDICATORS = [
1413
+ "Research failed",
1414
  "pipeline has been interrupted",
1415
  "An unexpected error occurred",
1416
  "Population failed",
1417
  "Pipeline failed",
1418
  "Something went wrong during the pipeline",
1419
+ "Traceback (most recent call last)",
1420
+ "NameError:",
1421
  ]
1422
  while time.time() - start < timeout_sec:
1423
  time.sleep(poll_interval)
tests/test_research_logging_regression.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression checks for research-phase session logging."""
2
+
3
+ import ast
4
+ from pathlib import Path
5
+
6
+
7
+ def test_fresh_research_binds_slog_before_use():
8
+ """Catch the UI-only NameError where _slog was referenced before assignment."""
9
+ source = Path("chat_interface.py").read_text()
10
+ module = ast.parse(source)
11
+ target = next(
12
+ node
13
+ for node in module.body
14
+ if isinstance(node, ast.ClassDef) and node.name == "ChatDemoInterface"
15
+ )
16
+ method = next(
17
+ node
18
+ for node in target.body
19
+ if isinstance(node, ast.FunctionDef) and node.name == "_run_fresh_research"
20
+ )
21
+
22
+ saw_assignment = False
23
+ for node in ast.walk(method):
24
+ if isinstance(node, ast.Name) and node.id == "_slog" and isinstance(node.ctx, ast.Load):
25
+ assert saw_assignment, "_slog is read before it is assigned in _run_fresh_research"
26
+ return
27
+ if isinstance(node, ast.Assign):
28
+ saw_assignment = any(
29
+ isinstance(target, ast.Name) and target.id == "_slog"
30
+ for target in node.targets
31
+ ) or saw_assignment
32
+
33
+ raise AssertionError("_run_fresh_research does not read _slog; regression test needs updating")