milwright commited on
Commit
951725a
Β·
verified Β·
1 Parent(s): 94b6675

Writing-07-31-2025-9:40PM

Browse files
Files changed (1) hide show
  1. app.py +413 -321
app.py CHANGED
@@ -10,39 +10,68 @@ import urllib.parse
10
 
11
 
12
  # Configuration
13
- SPACE_NAME = "'AI Assistant'"
14
- SPACE_DESCRIPTION = "'A customizable AI assistant'"
15
 
16
- # Default configuration values
17
- DEFAULT_SYSTEM_PROMPT = """You are a sentence-level writing and composition assistant specializing in micro-writing pedagogy. Work with student submissions of single sentences to one paragraph maximum, providing focused feedback on sentence structure, word choice, clarity, and flow. Guide students through incremental, single-paragraph improvements rather than wholesale rewrites and emphasize the building blocks of process-based writing and the rhetorical grammar of subject-verb relationships, citational practice, transitions and signposting, prosody and rhythm. Focus on sentence structure clarity and variety, precise word choice, transitional logic between ideas, concrete specificity over vague generalizations and genre-specific tone and voice. Frequently offer 1-2 specific, actionable suggestions per submission, celebrate what's working before suggesting improvements. Briefly unpack the **why** behind suggestions and ask at least one simple follow-up question to facilitate the next cycle. Maintain high fidelity to existing versions as a rule of thumb, and expect to complete the response in less than 400 tokens."""
18
- DEFAULT_TEMPERATURE = 0.5
19
- DEFAULT_MAX_TOKENS = 450
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- # Try to load configuration from file (if modified by faculty)
22
- try:
23
- with open('config.json', 'r') as f:
24
- saved_config = json.load(f)
25
- SYSTEM_PROMPT = saved_config.get('system_prompt', DEFAULT_SYSTEM_PROMPT)
26
- temperature = saved_config.get('temperature', DEFAULT_TEMPERATURE)
27
- max_tokens = saved_config.get('max_tokens', DEFAULT_MAX_TOKENS)
28
- print("βœ… Loaded configuration from config.json")
29
- except:
30
- # Use defaults if no config file or error
31
- SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT
32
- temperature = DEFAULT_TEMPERATURE
33
- max_tokens = DEFAULT_MAX_TOKENS
34
- print("ℹ️ Using default configuration")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
- MODEL = "'anthropic/claude-3.5-sonnet'"
37
- THEME = "Ocean" # Gradio theme name
38
- GROUNDING_URLS = ["https://owl.purdue.edu/owl/general_writing/index.html", "https://en.wikipedia.org/wiki/Academic_writing", "https://en.wikipedia.org/wiki/Essay"]
39
  # Get access code from environment variable for security
40
  # If ACCESS_CODE is not set, no access control is applied
41
  ACCESS_CODE = os.environ.get("ACCESS_CODE")
42
- ENABLE_DYNAMIC_URLS = True
43
 
44
  # Get API key from environment - customizable variable name with validation
45
- API_KEY = os.environ.get("'API_KEY'")
 
46
  if API_KEY:
47
  API_KEY = API_KEY.strip() # Remove any whitespace
48
  if not API_KEY: # Check if empty after stripping
@@ -53,21 +82,21 @@ def validate_api_key():
53
  """Validate API key configuration with detailed logging"""
54
  if not API_KEY:
55
  print(f"⚠️ API KEY CONFIGURATION ERROR:")
56
- print(f" Variable name: 'API_KEY'")
57
  print(f" Status: Not set or empty")
58
- print(f" Action needed: Set ''API_KEY'' in HuggingFace Space secrets")
59
  print(f" Expected format: sk-or-xxxxxxxxxx")
60
  return False
61
  elif not API_KEY.startswith('sk-or-'):
62
  print(f"⚠️ API KEY FORMAT WARNING:")
63
- print(f" Variable name: 'API_KEY'")
64
- print(f" Current value: {API_KEY[:10]}..." if len(API_KEY) > 10 else API_KEY)
65
  print(f" Expected format: sk-or-xxxxxxxxxx")
66
  print(f" Note: OpenRouter keys should start with 'sk-or-'")
67
  return True # Still try to use it
68
  else:
69
  print(f"βœ… API Key configured successfully")
70
- print(f" Variable: 'API_KEY'")
71
  print(f" Format: Valid OpenRouter key")
72
  return True
73
 
@@ -124,12 +153,13 @@ def fetch_url_content(url):
124
 
125
  # Smart truncation - try to end at sentence boundaries
126
  if len(text) > 4000:
127
- truncated = text[:4000]
128
- last_period = truncated.rfind('.')
129
- if last_period > 3000: # If we can find a reasonable sentence break
130
- text = truncated[:last_period + 1]
 
131
  else:
132
- text = truncated + "..."
133
 
134
  return text if text.strip() else "No readable content found at this URL"
135
 
@@ -142,7 +172,6 @@ def fetch_url_content(url):
142
 
143
  def extract_urls_from_text(text):
144
  """Extract URLs from text using regex with enhanced validation"""
145
- import re
146
  url_pattern = r'https?://[^\s<>"{}|\\^`\[\]"]+'
147
  urls = re.findall(url_pattern, text)
148
 
@@ -162,18 +191,26 @@ _url_content_cache = {}
162
 
163
  def get_grounding_context():
164
  """Fetch context from grounding URLs with caching"""
165
- if not GROUNDING_URLS:
 
 
 
 
 
 
 
 
166
  return ""
167
 
168
  # Create cache key from URLs
169
- cache_key = tuple(sorted([url for url in GROUNDING_URLS if url and url.strip()]))
170
 
171
  # Check cache first
172
  if cache_key in _url_content_cache:
173
  return _url_content_cache[cache_key]
174
 
175
  context_parts = []
176
- for i, url in enumerate(GROUNDING_URLS, 1):
177
  if url.strip():
178
  content = fetch_url_content(url.strip())
179
  # Add priority indicators
@@ -224,7 +261,7 @@ Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
224
  return markdown_content
225
 
226
 
227
- def generate_response(message, history):
228
  """Generate response using OpenRouter API"""
229
 
230
  # Enhanced API key validation with helpful messages
@@ -233,30 +270,42 @@ def generate_response(message, history):
233
  error_msg += f"Please configure your OpenRouter API key:\n"
234
  error_msg += f"1. Go to Settings (βš™οΈ) in your HuggingFace Space\n"
235
  error_msg += f"2. Click 'Variables and secrets'\n"
236
- error_msg += f"3. Add secret: **'API_KEY'**\n"
237
  error_msg += f"4. Value: Your OpenRouter API key (starts with `sk-or-`)\n\n"
238
  error_msg += f"Get your API key at: https://openrouter.ai/keys"
239
- print(f"❌ API request failed: No API key configured for 'API_KEY'")
240
  return error_msg
241
 
242
  # Get grounding context
243
  grounding_context = get_grounding_context()
244
 
 
 
 
 
 
 
 
 
 
 
 
245
 
 
 
 
246
  # If dynamic URLs are enabled, check message for URLs to fetch
247
  if ENABLE_DYNAMIC_URLS:
248
  urls_in_message = extract_urls_from_text(message)
249
  if urls_in_message:
250
- # Fetch content from URLs mentioned in the message
251
- dynamic_context_parts = []
252
  for url in urls_in_message[:3]: # Limit to 3 URLs per message
253
  content = fetch_url_content(url)
254
- dynamic_context_parts.append(f"\n\nDynamic context from {url}:\n{content}")
255
- if dynamic_context_parts:
256
- grounding_context += "\n".join(dynamic_context_parts)
257
 
258
- # Build enhanced system prompt with grounding context
259
- enhanced_system_prompt = SYSTEM_PROMPT + grounding_context
260
 
261
  # Build messages array for the API
262
  messages = [{"role": "system", "content": enhanced_system_prompt}]
@@ -264,15 +313,10 @@ def generate_response(message, history):
264
  # Add conversation history - handle both modern messages format and legacy tuples
265
  for chat in history:
266
  if isinstance(chat, dict):
267
- # Modern format: {"role": "user", "content": "..."} or {"role": "assistant", "content": "..."}
268
  messages.append(chat)
269
  elif isinstance(chat, (list, tuple)) and len(chat) >= 2:
270
- # Legacy format: ["user msg", "assistant msg"] or ("user msg", "assistant msg")
271
- user_msg, assistant_msg = chat[0], chat[1]
272
- if user_msg:
273
- messages.append({"role": "user", "content": user_msg})
274
- if assistant_msg:
275
- messages.append({"role": "assistant", "content": assistant_msg})
276
 
277
  # Add current message
278
  messages.append({"role": "user", "content": message})
@@ -288,14 +332,14 @@ def generate_response(message, history):
288
  headers={
289
  "Authorization": f"Bearer {API_KEY}",
290
  "Content-Type": "application/json",
291
- "HTTP-Referer": "https://huggingface.co", # Required by some providers
292
- "X-Title": "HuggingFace Space" # Helpful for tracking
293
  },
294
  json={
295
  "model": MODEL,
296
  "messages": messages,
297
- "temperature": 0.5,
298
- "max_tokens": 450
299
  },
300
  timeout=30
301
  )
@@ -305,40 +349,26 @@ def generate_response(message, history):
305
  if response.status_code == 200:
306
  try:
307
  result = response.json()
308
-
309
- # Enhanced validation of API response structure
310
- if 'choices' not in result or not result['choices']:
311
- print(f"⚠️ API response missing choices: {result}")
312
- return "API Error: No response choices available"
313
- elif 'message' not in result['choices'][0]:
314
- print(f"⚠️ API response missing message: {result}")
315
- return "API Error: No message in response"
316
- elif 'content' not in result['choices'][0]['message']:
317
- print(f"⚠️ API response missing content: {result}")
318
- return "API Error: No content in message"
319
- else:
320
- content = result['choices'][0]['message']['content']
321
-
322
- # Check for empty content
323
- if not content or content.strip() == "":
324
- print(f"⚠️ API returned empty content")
325
- return "API Error: Empty response content"
326
-
327
- print(f"βœ… API request successful")
328
- return content
329
-
330
  except (KeyError, IndexError, json.JSONDecodeError) as e:
331
- print(f"❌ Failed to parse API response: {str(e)}")
332
- return f"API Error: Failed to parse response - {str(e)}"
 
 
 
 
 
 
 
333
  elif response.status_code == 401:
334
- error_msg = f"πŸ” **Authentication Error**\n\n"
335
  error_msg += f"Your API key appears to be invalid or expired.\n\n"
336
  error_msg += f"**Troubleshooting:**\n"
337
- error_msg += f"1. Check that your **'API_KEY'** secret is set correctly\n"
338
- error_msg += f"2. Verify your API key at: https://openrouter.ai/keys\n"
339
- error_msg += f"3. Ensure your key starts with `sk-or-`\n"
340
- error_msg += f"4. Check that you have credits on your OpenRouter account"
341
- print(f"❌ API authentication failed: {response.status_code} - {response.text[:200]}")
342
  return error_msg
343
  elif response.status_code == 429:
344
  error_msg = f"⏱️ **Rate Limit Exceeded**\n\n"
@@ -346,31 +376,26 @@ def generate_response(message, history):
346
  error_msg += f"**Troubleshooting:**\n"
347
  error_msg += f"1. Wait 30-60 seconds before trying again\n"
348
  error_msg += f"2. Check your OpenRouter usage limits\n"
349
- error_msg += f"3. Consider upgrading your OpenRouter plan"
350
- print(f"❌ Rate limit exceeded: {response.status_code}")
351
  return error_msg
352
  elif response.status_code == 400:
353
- try:
354
- error_data = response.json()
355
- error_message = error_data.get('error', {}).get('message', 'Unknown error')
356
- except:
357
- error_message = response.text
358
-
359
- error_msg = f"⚠️ **Request Error**\n\n"
360
- error_msg += f"The API request was invalid:\n"
361
- error_msg += f"`{error_message}`\n\n"
362
- if "model" in error_message.lower():
363
- error_msg += f"**Model Issue:** The model `{MODEL}` may not be available.\n"
364
- error_msg += f"Try switching to a different model in your Space configuration."
365
- print(f"❌ Bad request: {response.status_code} - {error_message}")
366
  return error_msg
367
  else:
368
- error_msg = f"🚫 **API Error {response.status_code}**\n\n"
369
- error_msg += f"An unexpected error occurred. Please try again.\n\n"
370
- error_msg += f"If this persists, check:\n"
371
- error_msg += f"1. OpenRouter service status\n"
372
- error_msg += f"2. Your API key and credits\n"
373
- error_msg += f"3. The model availability"
 
374
  print(f"❌ API error: {response.status_code} - {response.text[:200]}")
375
  return error_msg
376
 
@@ -393,10 +418,10 @@ def generate_response(message, history):
393
  print(f"❌ Connection error to OpenRouter API")
394
  return error_msg
395
  except Exception as e:
396
- error_msg = f"❌ **Unexpected Error**\n\n"
397
- error_msg += f"An unexpected error occurred:\n"
398
  error_msg += f"`{str(e)}`\n\n"
399
- error_msg += f"Please try again or contact support if this persists."
400
  print(f"❌ Unexpected error: {str(e)}")
401
  return error_msg
402
 
@@ -409,38 +434,40 @@ def verify_access_code(code):
409
  global _access_granted_global
410
  if ACCESS_CODE is None:
411
  _access_granted_global = True
412
- return gr.update(visible=False), gr.update(visible=True), gr.update(value=True)
413
 
414
  if code == ACCESS_CODE:
415
  _access_granted_global = True
416
- return gr.update(visible=False), gr.update(visible=True), gr.update(value=True)
417
  else:
418
  _access_granted_global = False
419
- return gr.update(visible=True, value="❌ Incorrect access code. Please try again."), gr.update(visible=False), gr.update(value=False)
420
 
421
- def protected_generate_response(message, history):
422
  """Protected response function that checks access"""
423
  # Check if access is granted via the global variable
424
  if ACCESS_CODE is not None and not _access_granted_global:
425
  return "Please enter the access code to continue."
426
- return generate_response(message, history)
427
 
428
  # Global variable to store chat history for export
429
  chat_history_store = []
430
 
431
- def store_and_generate_response(message, history):
432
  """Wrapper function that stores history and generates response"""
433
  global chat_history_store
434
 
435
  # Generate response using the protected function
436
- response = protected_generate_response(message, history)
437
 
438
  # Convert current history to the format we need for export
439
  # history comes in as [["user1", "bot1"], ["user2", "bot2"], ...]
440
  chat_history_store = []
441
  if history:
442
  for exchange in history:
443
- if isinstance(exchange, (list, tuple)) and len(exchange) >= 2:
 
 
444
  chat_history_store.append({"role": "user", "content": exchange[0]})
445
  chat_history_store.append({"role": "assistant", "content": exchange[1]})
446
 
@@ -471,144 +498,205 @@ def export_conversation(history):
471
 
472
  markdown_content = export_conversation_to_markdown(history)
473
 
474
- # Create logical filename: conversation_YYYYMMDD_HHMMSS.md
475
- filename = create_safe_filename("conversation", suffix=".md", include_timestamp=True)
476
-
477
- # Save to temporary file with logical name
478
- import tempfile
479
- import os
480
- temp_dir = tempfile.gettempdir()
481
- temp_file = os.path.join(temp_dir, filename)
482
-
483
- with open(temp_file, 'w', encoding='utf-8') as f:
484
  f.write(markdown_content)
 
485
 
486
  return gr.update(value=temp_file, visible=True)
487
 
488
  # Configuration status display
489
  def get_configuration_status():
490
- """Generate a configuration status message for display"""
491
  status_parts = []
492
 
493
- # API Key status
494
- status_parts.append("### πŸ”‘ API Configuration")
495
- if API_KEY_VALID:
496
- status_parts.append("βœ… **API Key:** Ready")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  else:
498
- status_parts.append("❌ **API Key:** Not configured")
499
- status_parts.append(" Set `'API_KEY'` in Space secrets")
500
-
501
- # Model and parameters
502
- status_parts.append("") # Blank line
503
- status_parts.append("### πŸ€– Model Settings")
504
- status_parts.append(f"**Model:** {MODEL.split('/')[-1]}")
505
- status_parts.append(f"**Temperature:** 0.5")
506
- status_parts.append(f"**Max Tokens:** 450")
507
 
508
  # URL Context if configured
509
- if GROUNDING_URLS:
510
- status_parts.append("") # Blank line
511
- status_parts.append("### πŸ”— Context Sources")
512
- status_parts.append(f"**URLs Configured:** {len(GROUNDING_URLS)}")
513
- for i, url in enumerate(GROUNDING_URLS[:2], 1):
514
- status_parts.append(f" {i}. {url[:50]}{'...' if len(url) > 50 else ''}")
515
- if len(GROUNDING_URLS) > 2:
516
- status_parts.append(f" ... and {len(GROUNDING_URLS) - 2} more")
517
-
518
- # Access control
519
- if ACCESS_CODE is not None:
520
- status_parts.append("") # Blank line
521
- status_parts.append("### πŸ” Access Control")
522
- status_parts.append("**Status:** Password protected")
523
-
524
- # System prompt
525
- status_parts.append("") # Blank line
526
- status_parts.append("### πŸ“ System Prompt")
527
- # Show first 200 chars of system prompt
528
- prompt_preview = SYSTEM_PROMPT[:200] + "..." if len(SYSTEM_PROMPT) > 200 else SYSTEM_PROMPT
529
- status_parts.append(f"```\n{prompt_preview}\n```")
 
 
 
530
 
531
  return "\n".join(status_parts)
532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  # Create interface with access code protection
534
  # Dynamically set theme based on configuration
535
  theme_class = getattr(gr.themes, THEME, gr.themes.Default)
536
  with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
537
- gr.Markdown(f"# {SPACE_NAME}")
538
- gr.Markdown(SPACE_DESCRIPTION)
539
-
540
- # Access code section (shown only if ACCESS_CODE is set)
541
- with gr.Column(visible=(ACCESS_CODE is not None)) as access_section:
542
- gr.Markdown("### πŸ” Access Required")
543
- gr.Markdown("Please enter the access code provided by your instructor:")
544
-
545
- access_input = gr.Textbox(
546
- label="Access Code",
547
- placeholder="Enter access code...",
548
- type="password"
549
- )
550
- access_btn = gr.Button("Submit", variant="primary")
551
- access_error = gr.Markdown(visible=False)
552
-
553
- # Main chat interface (hidden until access granted)
554
- with gr.Column(visible=(ACCESS_CODE is None)) as chat_section:
555
- chat_interface = gr.ChatInterface(
556
- fn=store_and_generate_response, # Use wrapper function to store history
557
- title="", # Title already shown above
558
- description="", # Description already shown above
559
- examples=['Can you help me improve my thesis statement?', 'How should I structure my argumentative essay?', 'What makes a good academic paragraph?'],
560
- type="messages" # Use modern message format for better compatibility
561
- )
562
-
563
- # Export functionality
564
- with gr.Row():
565
- export_btn = gr.Button("πŸ“₯ Export Conversation", variant="secondary", size="sm")
566
- export_file = gr.File(label="Download", visible=False)
567
-
568
- # Connect export functionality
569
- export_btn.click(
570
- export_current_conversation,
571
- outputs=[export_file]
572
- )
573
-
574
-
575
- # Connect access verification
576
- if ACCESS_CODE is not None:
577
- access_btn.click(
578
- verify_access_code,
579
- inputs=[access_input],
580
- outputs=[access_error, chat_section, access_granted]
581
- )
582
- access_input.submit(
583
- verify_access_code,
584
- inputs=[access_input],
585
- outputs=[access_error, chat_section, access_granted]
586
- )
587
-
588
- # Faculty Configuration Section - appears at the bottom with password protection
589
- with gr.Accordion("πŸ”§ Faculty Configuration", open=False, visible=True) as faculty_section:
590
- gr.Markdown("**Faculty Only:** Edit assistant configuration. Requires CONFIG_CODE secret.")
591
-
592
- # Check if faculty password is configured
593
- FACULTY_PASSWORD = os.environ.get("CONFIG_CODE", "").strip()
594
-
595
- if FACULTY_PASSWORD:
596
- faculty_auth_state = gr.State(False)
597
-
598
- # Authentication row
599
- with gr.Column() as faculty_auth_row:
600
- with gr.Row():
601
- faculty_password_input = gr.Textbox(
602
- label="Faculty Password",
603
- type="password",
604
- placeholder="Enter faculty configuration password",
605
- scale=3
 
 
 
 
 
 
606
  )
607
- faculty_auth_btn = gr.Button("Unlock Configuration", variant="primary", scale=1)
608
- faculty_auth_status = gr.Markdown("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
 
610
- # Configuration editor (hidden until authenticated)
611
- with gr.Column(visible=False) as faculty_config_section:
 
 
 
 
 
 
 
 
 
 
612
  gr.Markdown("### Edit Assistant Configuration")
613
  gr.Markdown("⚠️ **Warning:** Changes will affect all users immediately.")
614
 
@@ -617,29 +705,11 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
617
  with open('config.json', 'r') as f:
618
  current_config = json.load(f)
619
  except:
620
- current_config = {
621
- 'system_prompt': SYSTEM_PROMPT,
622
- 'temperature': 0.5,
623
- 'max_tokens': 450,
624
- 'locked': False
625
- }
626
-
627
- # Editable fields - Order matches the Configuration tab
628
- # 1. Assistant Identity
629
- edit_name = gr.Textbox(
630
- label="Assistant Name",
631
- value=current_config.get('name', SPACE_NAME),
632
- placeholder="My AI Assistant"
633
- )
634
-
635
- edit_description = gr.Textbox(
636
- label="Assistant Description",
637
- value=current_config.get('description', SPACE_DESCRIPTION),
638
- lines=2,
639
- placeholder="A helpful AI assistant for..."
640
- )
641
 
642
- # 2. System Prompt
 
643
  edit_system_prompt = gr.Textbox(
644
  label="System Prompt",
645
  value=current_config.get('system_prompt', SYSTEM_PROMPT),
@@ -661,8 +731,8 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
661
  ],
662
  value=current_config.get('model', MODEL)
663
  )
664
-
665
- # 4. Example Prompts
666
  examples_value = current_config.get('examples', [])
667
  if isinstance(examples_value, list):
668
  examples_text_value = "\n".join(examples_value)
@@ -682,18 +752,18 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
682
  label="Temperature",
683
  minimum=0,
684
  maximum=2,
685
- value=current_config.get('temperature', 0.5),
686
  step=0.1
687
  )
688
  edit_max_tokens = gr.Slider(
689
  label="Max Tokens",
690
  minimum=50,
691
  maximum=4096,
692
- value=current_config.get('max_tokens', 450),
693
  step=50
694
  )
695
 
696
- # 6. URL Grounding
697
  gr.Markdown("### URL Grounding")
698
  grounding_urls_value = current_config.get('grounding_urls', [])
699
  if isinstance(grounding_urls_value, str):
@@ -720,39 +790,23 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
720
  )
721
 
722
  with gr.Row():
723
- save_config_btn = gr.Button("πŸ’Ύ Save Configuration", variant="primary")
724
- reset_config_btn = gr.Button("↩️ Reset to Defaults", variant="secondary")
725
 
726
  config_status = gr.Markdown("")
727
 
728
- # Faculty authentication function
729
- def verify_faculty_password(password):
730
- if password == FACULTY_PASSWORD:
731
- return (
732
- gr.update(value="βœ… Authentication successful!"),
733
- gr.update(visible=False), # Hide auth row
734
- gr.update(visible=True), # Show config section
735
- True # Update auth state
736
- )
737
- else:
738
- return (
739
- gr.update(value="❌ Invalid password"),
740
- gr.update(visible=True), # Keep auth row visible
741
- gr.update(visible=False), # Keep config hidden
742
- False # Auth failed
743
- )
744
 
745
  # Save configuration function
746
- def save_configuration(new_name, new_description, new_prompt, new_model, new_examples, new_temp, new_tokens, *url_values, lock_config, is_authenticated):
747
  if not is_authenticated:
748
- return "❌ Not authenticated"
749
 
750
  # Check if configuration is already locked
751
  try:
752
  with open('config.json', 'r') as f:
753
  existing_config = json.load(f)
754
  if existing_config.get('locked', False):
755
- return "πŸ”’ Configuration is locked and cannot be modified"
756
  except:
757
  pass
758
 
@@ -761,8 +815,8 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
761
  with open('config.json', 'r') as f:
762
  current_full_config = json.load(f)
763
  except:
764
- # If config.json doesn't exist, use global config
765
- current_full_config = config.copy()
766
 
767
  # Process example prompts
768
  examples_list = [ex.strip() for ex in new_examples.split('\n') if ex.strip()]
@@ -773,10 +827,27 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
773
  # Filter out empty URLs
774
  grounding_urls = [url.strip() for url in urls if url.strip()]
775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  # Update all editable fields while preserving everything else
777
  current_full_config.update({
778
- 'name': new_name,
779
- 'description': new_description,
780
  'system_prompt': new_prompt,
781
  'model': new_model,
782
  'examples': examples_list,
@@ -792,17 +863,52 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
792
  with open('config.json', 'w') as f:
793
  json.dump(current_full_config, f, indent=2)
794
 
795
- # Reload all configuration values
796
- reload_config_values()
 
797
 
798
- return f"βœ… Configuration saved successfully at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
799
  except Exception as e:
800
  return f"❌ Error saving configuration: {str(e)}"
801
 
802
  # Reset configuration function
803
  def reset_configuration(is_authenticated):
804
  if not is_authenticated:
805
- updates = ["❌ Not authenticated"] + [gr.update() for _ in range(16)] # 1 status + 16 fields
806
  return tuple(updates)
807
 
808
  # Check if locked
@@ -810,7 +916,7 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
810
  with open('config.json', 'r') as f:
811
  existing_config = json.load(f)
812
  if existing_config.get('locked', False):
813
- updates = ["πŸ”’ Configuration is locked"] + [gr.update() for _ in range(16)]
814
  return tuple(updates)
815
  except:
816
  pass
@@ -822,25 +928,25 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
822
  else:
823
  examples_text = ""
824
 
825
- # Get default URLs
826
  default_urls = DEFAULT_CONFIG.get('grounding_urls', [])
827
  if isinstance(default_urls, str):
828
  try:
829
- import ast
830
- default_urls = ast.literal_eval(default_urls)
831
  except:
832
  default_urls = []
 
 
833
 
834
  # Reset to original default values
835
  updates = [
836
- "βœ… Reset to default values",
837
- gr.update(value=DEFAULT_CONFIG.get('name', SPACE_NAME)),
838
- gr.update(value=DEFAULT_CONFIG.get('description', SPACE_DESCRIPTION)),
839
  gr.update(value=DEFAULT_CONFIG.get('system_prompt', SYSTEM_PROMPT)),
840
  gr.update(value=DEFAULT_CONFIG.get('model', MODEL)),
841
  gr.update(value=examples_text),
842
- gr.update(value=DEFAULT_CONFIG.get('temperature', 0.5)),
843
- gr.update(value=DEFAULT_CONFIG.get('max_tokens', 450))
844
  ]
845
 
846
  # Add URL updates
@@ -850,33 +956,19 @@ with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
850
 
851
  return tuple(updates)
852
 
853
- # Connect authentication
854
- faculty_auth_btn.click(
855
- verify_faculty_password,
856
- inputs=[faculty_password_input],
857
- outputs=[faculty_auth_status, faculty_auth_row, faculty_config_section, faculty_auth_state]
858
- )
859
-
860
- faculty_password_input.submit(
861
- verify_faculty_password,
862
- inputs=[faculty_password_input],
863
- outputs=[faculty_auth_status, faculty_auth_row, faculty_config_section, faculty_auth_state]
864
- )
865
 
866
  # Connect configuration buttons
867
  save_config_btn.click(
868
  save_configuration,
869
- inputs=[edit_name, edit_description, edit_system_prompt, edit_model, edit_examples, edit_temperature, edit_max_tokens] + url_fields + [config_locked, faculty_auth_state],
870
  outputs=[config_status]
871
  )
872
 
873
  reset_config_btn.click(
874
  reset_configuration,
875
  inputs=[faculty_auth_state],
876
- outputs=[config_status, edit_name, edit_description, edit_system_prompt, edit_model, edit_examples, edit_temperature, edit_max_tokens] + url_fields
877
  )
878
- else:
879
- gr.Markdown("ℹ️ Faculty configuration is not enabled. Set CONFIG_CODE in Space secrets to enable.")
880
 
881
  if __name__ == "__main__":
882
  demo.launch()
 
10
 
11
 
12
  # Configuration
13
+ SPACE_NAME = 'AI Assistant'
14
+ SPACE_DESCRIPTION = 'A customizable AI assistant'
15
 
16
+ # Default configuration values (used only if config.json is missing)
17
+ DEFAULT_CONFIG = {
18
+ 'name': SPACE_NAME,
19
+ 'description': SPACE_DESCRIPTION,
20
+ 'system_prompt': "You are a sentence-level writing and composition assistant specializing in micro-writing pedagogy. Work with student submissions of single sentences to one paragraph maximum, providing focused feedback on sentence structure, word choice, clarity, and flow. Guide students through incremental, single-paragraph improvements rather than wholesale rewrites and emphasize the building blocks of process-based writing and the rhetorical grammar of subject-verb relationships, citational practice, transitions and signposting, prosody and rhythm. Focus on sentence structure clarity and variety, precise word choice, transitional logic between ideas, concrete specificity over vague generalizations and genre-specific tone and voice. Frequently offer 1-2 specific, actionable suggestions per submission, celebrate what's working before suggesting improvements. Briefly unpack the **why** behind suggestions and ask at least one simple follow-up question to facilitate the next cycle. Maintain high fidelity to existing versions as a rule of thumb, and expect to complete the response in less than 400 tokens.",
21
+ 'temperature': 0.5,
22
+ 'max_tokens': 450,
23
+ 'model': 'anthropic/claude-3.5-sonnet',
24
+ 'api_key_var': 'API_KEY',
25
+ 'theme': 'Ocean',
26
+ 'grounding_urls': ["https://owl.purdue.edu/owl/general_writing/index.html", "https://en.wikipedia.org/wiki/Academic_writing", "https://en.wikipedia.org/wiki/Essay"],
27
+ 'enable_dynamic_urls': True,
28
+ 'examples': ['Can you help me improve my thesis statement?', 'How should I structure my argumentative essay?', 'What makes a good academic paragraph?'],
29
+ 'locked': False
30
+ }
31
 
32
+ # Load configuration from file - this is the single source of truth
33
+ def load_config():
34
+ """Load configuration from config.json with fallback to defaults"""
35
+ try:
36
+ with open('config.json', 'r') as f:
37
+ config = json.load(f)
38
+ print("βœ… Loaded configuration from config.json")
39
+ return config
40
+ except FileNotFoundError:
41
+ print("ℹ️ No config.json found, using default configuration")
42
+ # Save default config for future use
43
+ try:
44
+ with open('config.json', 'w') as f:
45
+ json.dump(DEFAULT_CONFIG, f, indent=2)
46
+ print("βœ… Created config.json with default values")
47
+ except:
48
+ pass
49
+ return DEFAULT_CONFIG
50
+ except Exception as e:
51
+ print(f"⚠️ Error loading config.json: {e}, using defaults")
52
+ return DEFAULT_CONFIG
53
+
54
+ # Load configuration
55
+ config = load_config()
56
+
57
+ # Initial load of configuration values
58
+ SPACE_NAME = config.get('name', DEFAULT_CONFIG['name'])
59
+ SPACE_DESCRIPTION = config.get('description', DEFAULT_CONFIG['description'])
60
+ SYSTEM_PROMPT = config.get('system_prompt', DEFAULT_CONFIG['system_prompt'])
61
+ temperature = config.get('temperature', DEFAULT_CONFIG['temperature'])
62
+ max_tokens = config.get('max_tokens', DEFAULT_CONFIG['max_tokens'])
63
+ MODEL = config.get('model', DEFAULT_CONFIG['model'])
64
+ THEME = config.get('theme', DEFAULT_CONFIG['theme'])
65
+ GROUNDING_URLS = config.get('grounding_urls', DEFAULT_CONFIG['grounding_urls'])
66
+ ENABLE_DYNAMIC_URLS = config.get('enable_dynamic_urls', DEFAULT_CONFIG['enable_dynamic_urls'])
67
 
 
 
 
68
  # Get access code from environment variable for security
69
  # If ACCESS_CODE is not set, no access control is applied
70
  ACCESS_CODE = os.environ.get("ACCESS_CODE")
 
71
 
72
  # Get API key from environment - customizable variable name with validation
73
+ API_KEY_VAR = config.get('api_key_var', DEFAULT_CONFIG['api_key_var'])
74
+ API_KEY = os.environ.get(API_KEY_VAR)
75
  if API_KEY:
76
  API_KEY = API_KEY.strip() # Remove any whitespace
77
  if not API_KEY: # Check if empty after stripping
 
82
  """Validate API key configuration with detailed logging"""
83
  if not API_KEY:
84
  print(f"⚠️ API KEY CONFIGURATION ERROR:")
85
+ print(f" Variable name: {API_KEY_VAR}")
86
  print(f" Status: Not set or empty")
87
+ print(f" Action needed: Set '{API_KEY_VAR}' in HuggingFace Space secrets")
88
  print(f" Expected format: sk-or-xxxxxxxxxx")
89
  return False
90
  elif not API_KEY.startswith('sk-or-'):
91
  print(f"⚠️ API KEY FORMAT WARNING:")
92
+ print(f" Variable name: {API_KEY_VAR}")
93
+ print(f" Current value: {API_KEY[:10]}..." if len(API_KEY) > 10 else "{API_KEY}")
94
  print(f" Expected format: sk-or-xxxxxxxxxx")
95
  print(f" Note: OpenRouter keys should start with 'sk-or-'")
96
  return True # Still try to use it
97
  else:
98
  print(f"βœ… API Key configured successfully")
99
+ print(f" Variable: {API_KEY_VAR}")
100
  print(f" Format: Valid OpenRouter key")
101
  return True
102
 
 
153
 
154
  # Smart truncation - try to end at sentence boundaries
155
  if len(text) > 4000:
156
+ truncated_text = text[:4000]
157
+ # Try to find the last complete sentence
158
+ last_period = truncated_text.rfind('.')
159
+ if last_period > 3500: # Only if we have a reasonably long truncation
160
+ text = truncated_text[:last_period + 1]
161
  else:
162
+ text = truncated_text + "..."
163
 
164
  return text if text.strip() else "No readable content found at this URL"
165
 
 
172
 
173
  def extract_urls_from_text(text):
174
  """Extract URLs from text using regex with enhanced validation"""
 
175
  url_pattern = r'https?://[^\s<>"{}|\\^`\[\]"]+'
176
  urls = re.findall(url_pattern, text)
177
 
 
191
 
192
  def get_grounding_context():
193
  """Fetch context from grounding URLs with caching"""
194
+ # Handle both string and list formats for grounding_urls
195
+ urls = GROUNDING_URLS
196
+ if isinstance(urls, str):
197
+ try:
198
+ urls = json.loads(urls)
199
+ except:
200
+ urls = []
201
+
202
+ if not urls:
203
  return ""
204
 
205
  # Create cache key from URLs
206
+ cache_key = tuple(sorted([url for url in urls if url and url.strip()]))
207
 
208
  # Check cache first
209
  if cache_key in _url_content_cache:
210
  return _url_content_cache[cache_key]
211
 
212
  context_parts = []
213
+ for i, url in enumerate(urls, 1):
214
  if url.strip():
215
  content = fetch_url_content(url.strip())
216
  # Add priority indicators
 
261
  return markdown_content
262
 
263
 
264
+ def generate_response(message, history, files=None):
265
  """Generate response using OpenRouter API"""
266
 
267
  # Enhanced API key validation with helpful messages
 
270
  error_msg += f"Please configure your OpenRouter API key:\n"
271
  error_msg += f"1. Go to Settings (βš™οΈ) in your HuggingFace Space\n"
272
  error_msg += f"2. Click 'Variables and secrets'\n"
273
+ error_msg += f"3. Add secret: **{API_KEY_VAR}**\n"
274
  error_msg += f"4. Value: Your OpenRouter API key (starts with `sk-or-`)\n\n"
275
  error_msg += f"Get your API key at: https://openrouter.ai/keys"
276
+ print(f"❌ API request failed: No API key configured for {API_KEY_VAR}")
277
  return error_msg
278
 
279
  # Get grounding context
280
  grounding_context = get_grounding_context()
281
 
282
+ # Process uploaded files if any
283
+ file_context = ""
284
+ if files:
285
+ file_contents = []
286
+ for file_obj in files:
287
+ if file_obj is not None:
288
+ try:
289
+ file_content = extract_file_content(file_obj.name)
290
+ file_contents.append(file_content)
291
+ except Exception as e:
292
+ file_contents.append(f"Error processing file: {str(e)}")
293
 
294
+ if file_contents:
295
+ file_context = "\n\n[UPLOADED FILES]\n" + "\n\n".join(file_contents) + "\n"
296
+
297
  # If dynamic URLs are enabled, check message for URLs to fetch
298
  if ENABLE_DYNAMIC_URLS:
299
  urls_in_message = extract_urls_from_text(message)
300
  if urls_in_message:
301
+ dynamic_context = ""
 
302
  for url in urls_in_message[:3]: # Limit to 3 URLs per message
303
  content = fetch_url_content(url)
304
+ dynamic_context += f"\n\n[DYNAMIC] Context from {url}:\n{content}"
305
+ grounding_context += dynamic_context
 
306
 
307
+ # Build enhanced system prompt with grounding context and file content
308
+ enhanced_system_prompt = SYSTEM_PROMPT + grounding_context + file_context
309
 
310
  # Build messages array for the API
311
  messages = [{"role": "system", "content": enhanced_system_prompt}]
 
313
  # Add conversation history - handle both modern messages format and legacy tuples
314
  for chat in history:
315
  if isinstance(chat, dict):
 
316
  messages.append(chat)
317
  elif isinstance(chat, (list, tuple)) and len(chat) >= 2:
318
+ messages.append({"role": "user", "content": chat[0]})
319
+ messages.append({"role": "assistant", "content": chat[1]})
 
 
 
 
320
 
321
  # Add current message
322
  messages.append({"role": "user", "content": message})
 
332
  headers={
333
  "Authorization": f"Bearer {API_KEY}",
334
  "Content-Type": "application/json",
335
+ "HTTP-Referer": "https://huggingface.co",
336
+ "X-Title": "HuggingFace Space"
337
  },
338
  json={
339
  "model": MODEL,
340
  "messages": messages,
341
+ "temperature": temperature,
342
+ "max_tokens": max_tokens
343
  },
344
  timeout=30
345
  )
 
349
  if response.status_code == 200:
350
  try:
351
  result = response.json()
352
+ return result['choices'][0]['message']['content']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  except (KeyError, IndexError, json.JSONDecodeError) as e:
354
+ error_msg = f"❌ **Response Parsing Error**\n\n"
355
+ error_msg += f"Received response from API but couldn't parse it properly.\n"
356
+ error_msg += f"Error: {str(e)}\n\n"
357
+ error_msg += f"**Troubleshooting:**\n"
358
+ error_msg += f"1. Check OpenRouter service status\n"
359
+ error_msg += f"2. Try again in a few moments\n"
360
+ error_msg += f"3. Try a different model if available"
361
+ print(f"❌ Response parsing error: {str(e)}")
362
+ return error_msg
363
  elif response.status_code == 401:
364
+ error_msg = f"πŸ”‘ **Authentication Error**\n\n"
365
  error_msg += f"Your API key appears to be invalid or expired.\n\n"
366
  error_msg += f"**Troubleshooting:**\n"
367
+ error_msg += f"1. Check that your **{API_KEY_VAR}** secret is set correctly\n"
368
+ error_msg += f"2. Verify your OpenRouter API key at https://openrouter.ai/keys\n"
369
+ error_msg += f"3. Make sure the key starts with `sk-or-`\n"
370
+ error_msg += f"4. Check if you have sufficient credits"
371
+ print(f"❌ Authentication failed: Invalid API key")
372
  return error_msg
373
  elif response.status_code == 429:
374
  error_msg = f"⏱️ **Rate Limit Exceeded**\n\n"
 
376
  error_msg += f"**Troubleshooting:**\n"
377
  error_msg += f"1. Wait 30-60 seconds before trying again\n"
378
  error_msg += f"2. Check your OpenRouter usage limits\n"
379
+ print(f"❌ Rate limit exceeded")
 
380
  return error_msg
381
  elif response.status_code == 400:
382
+ error_msg = f"πŸ“ **Request Error**\n\n"
383
+ error_msg += f"There was a problem with the request format.\n"
384
+ error_msg += f"Response: {response.text[:500]}\n\n"
385
+ error_msg += f"**Troubleshooting:**\n"
386
+ error_msg += f"1. Try a shorter message\n"
387
+ error_msg += f"2. Check for special characters in your message\n"
388
+ error_msg += f"3. Try a different model"
389
+ print(f"❌ Bad request: {response.status_code} - {response.text[:200]}")
 
 
 
 
 
390
  return error_msg
391
  else:
392
+ error_msg = f"🌐 **API Error {response.status_code}**\n\n"
393
+ error_msg += f"An unexpected error occurred.\n"
394
+ error_msg += f"Response: {response.text[:500]}\n\n"
395
+ error_msg += f"**Troubleshooting:**\n"
396
+ error_msg += f"1. Try again in a few moments\n"
397
+ error_msg += f"2. Check OpenRouter service status\n"
398
+ error_msg += f"3. Contact support if this persists"
399
  print(f"❌ API error: {response.status_code} - {response.text[:200]}")
400
  return error_msg
401
 
 
418
  print(f"❌ Connection error to OpenRouter API")
419
  return error_msg
420
  except Exception as e:
421
+ error_msg = "❌ **Unexpected Error**\n\n"
422
+ error_msg += "An unexpected error occurred:\n"
423
  error_msg += f"`{str(e)}`\n\n"
424
+ error_msg += "Please try again or contact support if this persists."
425
  print(f"❌ Unexpected error: {str(e)}")
426
  return error_msg
427
 
 
434
  global _access_granted_global
435
  if ACCESS_CODE is None:
436
  _access_granted_global = True
437
+ return gr.update(value="No access code required.", style={"color": "green"}), gr.update(visible=True), True
438
 
439
  if code == ACCESS_CODE:
440
  _access_granted_global = True
441
+ return gr.update(value="βœ… Access granted!", style={"color": "green"}), gr.update(visible=True), True
442
  else:
443
  _access_granted_global = False
444
+ return gr.update(value="❌ Invalid access code. Please try again.", style={"color": "red"}), gr.update(visible=False), False
445
 
446
+ def protected_generate_response(message, history, files=None):
447
  """Protected response function that checks access"""
448
  # Check if access is granted via the global variable
449
  if ACCESS_CODE is not None and not _access_granted_global:
450
  return "Please enter the access code to continue."
451
+ return generate_response(message, history, files)
452
 
453
  # Global variable to store chat history for export
454
  chat_history_store = []
455
 
456
+ def store_and_generate_response(message, history, files=None):
457
  """Wrapper function that stores history and generates response"""
458
  global chat_history_store
459
 
460
  # Generate response using the protected function
461
+ response = protected_generate_response(message, history, files)
462
 
463
  # Convert current history to the format we need for export
464
  # history comes in as [["user1", "bot1"], ["user2", "bot2"], ...]
465
  chat_history_store = []
466
  if history:
467
  for exchange in history:
468
+ if isinstance(exchange, dict):
469
+ chat_history_store.append(exchange)
470
+ elif isinstance(exchange, (list, tuple)) and len(exchange) >= 2:
471
  chat_history_store.append({"role": "user", "content": exchange[0]})
472
  chat_history_store.append({"role": "assistant", "content": exchange[1]})
473
 
 
498
 
499
  markdown_content = export_conversation_to_markdown(history)
500
 
501
+ # Save to temporary file
502
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f:
 
 
 
 
 
 
 
 
503
  f.write(markdown_content)
504
+ temp_file = f.name
505
 
506
  return gr.update(value=temp_file, visible=True)
507
 
508
  # Configuration status display
509
  def get_configuration_status():
510
+ """Generate a clean configuration status message for display"""
511
  status_parts = []
512
 
513
+ # Basic configuration info (without redundant "Configuration:" header)
514
+ status_parts.append(f"**Name:** {SPACE_NAME}")
515
+ status_parts.append(f"**Model:** {MODEL}")
516
+ status_parts.append(f"**Theme:** {THEME}")
517
+ status_parts.append(f"**Temperature:** {temperature}")
518
+ status_parts.append(f"**Max Response Tokens:** {max_tokens}")
519
+ status_parts.append("")
520
+
521
+ # Example prompts
522
+ status_parts.append("")
523
+ examples_list = config.get('examples', [])
524
+ if isinstance(examples_list, str):
525
+ try:
526
+ import ast
527
+ examples_list = ast.literal_eval(examples_list)
528
+ except:
529
+ examples_list = []
530
+
531
+ if examples_list and len(examples_list) > 0:
532
+ status_parts.append("**Example Prompts:**")
533
+ for example in examples_list[:5]: # Show first 5 examples
534
+ status_parts.append(f"β€’ {example}")
535
+ if len(examples_list) > 5:
536
+ status_parts.append(f"β€’ ... and {len(examples_list) - 5} more")
537
  else:
538
+ status_parts.append("**Example Prompts:** No example prompts configured")
 
 
 
 
 
 
 
 
539
 
540
  # URL Context if configured
541
+ urls = GROUNDING_URLS
542
+ if isinstance(urls, str):
543
+ try:
544
+ import ast
545
+ urls = ast.literal_eval(urls)
546
+ except:
547
+ urls = []
548
+
549
+ if urls and len(urls) > 0:
550
+ status_parts.append("")
551
+ status_parts.append("**Grounding URLs:**")
552
+ for i, url in enumerate(urls[:5], 1): # Show first 5 URLs
553
+ status_parts.append(f"{i}. {url}")
554
+ if len(urls) > 5:
555
+ status_parts.append(f"... and {len(urls) - 5} more URLs")
556
+
557
+ # System prompt at the end
558
+ status_parts.append("")
559
+ status_parts.append(f"**System Prompt:** {SYSTEM_PROMPT}")
560
+
561
+ # API Key status (minimal, at the end)
562
+ status_parts.append("")
563
+ if not API_KEY_VALID:
564
+ status_parts.append(f"**Note:** API key ({API_KEY_VAR}) not configured in Space secrets")
565
 
566
  return "\n".join(status_parts)
567
 
568
+ # HuggingFace Authentication Utility
569
+ def verify_hf_token_access():
570
+ """Verify HF_TOKEN has write access to the space"""
571
+ hf_token = os.environ.get("HF_TOKEN")
572
+ space_id = os.environ.get("SPACE_ID")
573
+
574
+ if not hf_token or not space_id:
575
+ return False, "Missing HF_TOKEN or SPACE_ID environment variables"
576
+
577
+ try:
578
+ from huggingface_hub import HfApi
579
+ api = HfApi(token=hf_token)
580
+ # Test access by getting space info
581
+ api.space_info(space_id)
582
+ return True, "Authenticated successfully"
583
+ except Exception as e:
584
+ return False, f"Authentication failed: {str(e)}"
585
+
586
  # Create interface with access code protection
587
  # Dynamically set theme based on configuration
588
  theme_class = getattr(gr.themes, THEME, gr.themes.Default)
589
  with gr.Blocks(title=SPACE_NAME, theme=theme_class()) as demo:
590
+ # Check if HF_TOKEN is configured to determine configuration panel availability
591
+ HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
592
+ SPACE_ID = os.environ.get("SPACE_ID", "").strip()
593
+
594
+ # Verify actual HuggingFace API access
595
+ HF_ACCESS_VALID, HF_ACCESS_MESSAGE = verify_hf_token_access()
596
+
597
+ # Always use tabs structure, Configuration tab visible only with HF_TOKEN
598
+ with gr.Tabs() as main_tabs:
599
+ with gr.Tab("Chat U/I"):
600
+ gr.Markdown(f"# {SPACE_NAME}")
601
+ gr.Markdown(SPACE_DESCRIPTION)
602
+
603
+ # Access code section (shown only if ACCESS_CODE is set)
604
+ with gr.Column(visible=(ACCESS_CODE is not None)) as access_section:
605
+ gr.Markdown("### πŸ” Access Required")
606
+ gr.Markdown("Please enter the access code provided by your instructor:")
607
+
608
+ access_input = gr.Textbox(
609
+ label="Access Code",
610
+ placeholder="Enter access code...",
611
+ type="password"
612
+ )
613
+ access_btn = gr.Button("Submit", variant="primary")
614
+ access_error = gr.Markdown(visible=False)
615
+
616
+ # Main chat interface (hidden until access granted)
617
+ with gr.Column(visible=(ACCESS_CODE is None)) as chat_section:
618
+ # Get examples from config
619
+ examples = config.get('examples', [])
620
+ if isinstance(examples, str):
621
+ try:
622
+ import ast
623
+ examples = ast.literal_eval(examples)
624
+ except:
625
+ examples = []
626
+
627
+ # Format examples for ChatInterface with additional_inputs
628
+ # When additional_inputs are present, examples must be list of lists
629
+ # where each inner list contains [message, *additional_input_values]
630
+ formatted_examples = None
631
+ if examples:
632
+ # Check if examples are already formatted correctly (list of lists)
633
+ if examples and isinstance(examples[0], list):
634
+ # Already formatted, use as-is
635
+ formatted_examples = examples
636
+ else:
637
+ # Format as [message, file_input] where file_input=None for examples
638
+ formatted_examples = [[example, None] for example in examples]
639
+
640
+ chat_interface = gr.ChatInterface(
641
+ fn=store_and_generate_response, # Use wrapper function to store history
642
+ title="", # Title already shown above
643
+ description="", # Description already shown above
644
+ examples=formatted_examples,
645
+ type="messages", # Use modern message format for better compatibility
646
+ additional_inputs=[
647
+ gr.File(
648
+ label="πŸ“Ž",
649
+ file_types=None, # Accept all file types
650
+ file_count="multiple",
651
+ visible=True
652
+ )
653
+ ]
654
+ )
655
+
656
+ # Export functionality
657
+ with gr.Row():
658
+ export_btn = gr.Button("πŸ“₯ Export Conversation", variant="secondary", size="sm")
659
+ export_file = gr.File(label="Download", visible=False)
660
+
661
+ # Connect export functionality
662
+ export_btn.click(
663
+ export_current_conversation,
664
+ outputs=[export_file]
665
  )
666
+
667
+ # Configuration status
668
+ with gr.Accordion("Configuration", open=False):
669
+ gr.Markdown(get_configuration_status())
670
+
671
+ # Connect access verification within tab context
672
+ if ACCESS_CODE is not None:
673
+ access_btn.click(
674
+ verify_access_code,
675
+ inputs=[access_input],
676
+ outputs=[access_error, chat_section, access_granted]
677
+ )
678
+ access_input.submit(
679
+ verify_access_code,
680
+ inputs=[access_input],
681
+ outputs=[access_error, chat_section, access_granted]
682
+ )
683
+
684
+ # Add Configuration tab (only visible with valid HF_TOKEN)
685
+ with gr.Tab("Configuration", visible=HF_ACCESS_VALID) as config_tab:
686
+ gr.Markdown("## Configuration Management")
687
 
688
+ # Show authentication status
689
+ if HF_ACCESS_VALID:
690
+ gr.Markdown(f"βœ… **Authenticated** - {HF_ACCESS_MESSAGE}")
691
+ gr.Markdown("Configuration changes will be saved to the HuggingFace repository and the Space will restart automatically.")
692
+ faculty_auth_state = gr.State(True)
693
+ else:
694
+ gr.Markdown(f"❌ **Not Available** - {HF_ACCESS_MESSAGE}")
695
+ gr.Markdown("Set HF_TOKEN and SPACE_ID in Space secrets to enable configuration management.")
696
+ faculty_auth_state = gr.State(False)
697
+
698
+ # Configuration editor (visible if HF authentication is valid)
699
+ with gr.Column(visible=HF_ACCESS_VALID) as faculty_config_section:
700
  gr.Markdown("### Edit Assistant Configuration")
701
  gr.Markdown("⚠️ **Warning:** Changes will affect all users immediately.")
702
 
 
705
  with open('config.json', 'r') as f:
706
  current_config = json.load(f)
707
  except:
708
+ # Use DEFAULT_CONFIG as fallback
709
+ current_config = DEFAULT_CONFIG.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
 
711
+ # Editable fields
712
+ # System Prompt
713
  edit_system_prompt = gr.Textbox(
714
  label="System Prompt",
715
  value=current_config.get('system_prompt', SYSTEM_PROMPT),
 
731
  ],
732
  value=current_config.get('model', MODEL)
733
  )
734
+
735
+ # 4. Example prompts field
736
  examples_value = current_config.get('examples', [])
737
  if isinstance(examples_value, list):
738
  examples_text_value = "\n".join(examples_value)
 
752
  label="Temperature",
753
  minimum=0,
754
  maximum=2,
755
+ value=current_config.get('temperature', 0.7),
756
  step=0.1
757
  )
758
  edit_max_tokens = gr.Slider(
759
  label="Max Tokens",
760
  minimum=50,
761
  maximum=4096,
762
+ value=current_config.get('max_tokens', 750),
763
  step=50
764
  )
765
 
766
+ # URL Grounding fields
767
  gr.Markdown("### URL Grounding")
768
  grounding_urls_value = current_config.get('grounding_urls', [])
769
  if isinstance(grounding_urls_value, str):
 
790
  )
791
 
792
  with gr.Row():
793
+ save_config_btn = gr.Button("Save Configuration", variant="primary")
794
+ reset_config_btn = gr.Button("Reset to Defaults", variant="secondary")
795
 
796
  config_status = gr.Markdown("")
797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
 
799
  # Save configuration function
800
+ def save_configuration(is_authenticated, new_prompt, new_model, new_examples, new_temp, new_tokens, *url_values):
801
  if not is_authenticated:
802
+ return "Not authenticated"
803
 
804
  # Check if configuration is already locked
805
  try:
806
  with open('config.json', 'r') as f:
807
  existing_config = json.load(f)
808
  if existing_config.get('locked', False):
809
+ return "Configuration is locked and cannot be modified"
810
  except:
811
  pass
812
 
 
815
  with open('config.json', 'r') as f:
816
  current_full_config = json.load(f)
817
  except:
818
+ # If config.json doesn't exist, use default configuration
819
+ current_full_config = DEFAULT_CONFIG.copy()
820
 
821
  # Process example prompts
822
  examples_list = [ex.strip() for ex in new_examples.split('\n') if ex.strip()]
 
827
  # Filter out empty URLs
828
  grounding_urls = [url.strip() for url in urls if url.strip()]
829
 
830
+ # Create backup before making changes
831
+ try:
832
+ # Create backups directory if it doesn't exist
833
+ os.makedirs('config_backups', exist_ok=True)
834
+
835
+ # Create timestamped backup
836
+ backup_filename = f"config_backups/config_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
837
+ with open(backup_filename, 'w') as backup_file:
838
+ json.dump(current_full_config, backup_file, indent=2)
839
+
840
+ # Keep only last 10 backups
841
+ backups = sorted([f for f in os.listdir('config_backups') if f.endswith('.json')])
842
+ if len(backups) > 10:
843
+ for old_backup in backups[:-10]:
844
+ os.remove(os.path.join('config_backups', old_backup))
845
+ except Exception as backup_error:
846
+ print(f"Warning: Could not create backup: {backup_error}")
847
+ # Continue with save even if backup fails
848
+
849
  # Update all editable fields while preserving everything else
850
  current_full_config.update({
 
 
851
  'system_prompt': new_prompt,
852
  'model': new_model,
853
  'examples': examples_list,
 
863
  with open('config.json', 'w') as f:
864
  json.dump(current_full_config, f, indent=2)
865
 
866
+ # Optional: Auto-commit to HuggingFace if token is available
867
+ hf_token = os.environ.get("HF_TOKEN")
868
+ space_id = os.environ.get("SPACE_ID")
869
 
870
+ if hf_token and space_id:
871
+ try:
872
+ from huggingface_hub import HfApi, CommitOperationAdd, restart_space
873
+ api = HfApi(token=hf_token)
874
+
875
+ # Create commit operation to upload config.json
876
+ operations = [
877
+ CommitOperationAdd(
878
+ path_or_fileobj="config.json",
879
+ path_in_repo="config.json"
880
+ )
881
+ ]
882
+
883
+ # Create commit with updated configuration
884
+ api.create_commit(
885
+ repo_id=space_id,
886
+ operations=operations,
887
+ commit_message=f"Update configuration by faculty at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
888
+ commit_description="Faculty configuration update through web interface",
889
+ repo_type="space",
890
+ token=hf_token
891
+ )
892
+
893
+ # Automatic restart
894
+ try:
895
+ restart_space(space_id, token=hf_token)
896
+ return f"βœ… Configuration saved and committed at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\nπŸ”„ **Space is restarting automatically!**\n\nThe page will refresh in about 30 seconds. Your changes will be applied."
897
+ except Exception as restart_error:
898
+ print(f"Could not auto-restart: {restart_error}")
899
+ return f"βœ… Configuration saved and committed at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\nπŸ”„ **Please restart manually** (auto-restart failed)\n\n1. Go to Settings (βš™οΈ)\n2. Click 'Factory reboot'\n3. Wait ~30 seconds"
900
+ except Exception as commit_error:
901
+ print(f"Note: Could not auto-commit to repository: {commit_error}")
902
+ return f"βœ… Configuration saved locally at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\nπŸ”„ **Manual Restart Required**\nFor changes to take effect:\n1. Go to Settings (βš™οΈ)\n2. Click 'Factory reboot'\n3. Wait ~30 seconds for restart"
903
+ else:
904
+ return f"βœ… Configuration saved at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\nπŸ”„ **Manual Restart Required**\nFor changes to take effect:\n1. Go to Settings (βš™οΈ)\n2. Click 'Factory reboot'\n3. Wait ~30 seconds for restart"
905
  except Exception as e:
906
  return f"❌ Error saving configuration: {str(e)}"
907
 
908
  # Reset configuration function
909
  def reset_configuration(is_authenticated):
910
  if not is_authenticated:
911
+ updates = ["Not authenticated"] + [gr.update() for _ in range(14)] # 1 status + 14 fields (prompt, model, examples, temp, tokens + 10 urls)
912
  return tuple(updates)
913
 
914
  # Check if locked
 
916
  with open('config.json', 'r') as f:
917
  existing_config = json.load(f)
918
  if existing_config.get('locked', False):
919
+ updates = ["Configuration is locked"] + [gr.update() for _ in range(14)]
920
  return tuple(updates)
921
  except:
922
  pass
 
928
  else:
929
  examples_text = ""
930
 
931
+ # Get default URLs - parse from JSON string if needed
932
  default_urls = DEFAULT_CONFIG.get('grounding_urls', [])
933
  if isinstance(default_urls, str):
934
  try:
935
+ import json
936
+ default_urls = json.loads(default_urls)
937
  except:
938
  default_urls = []
939
+ elif not isinstance(default_urls, list):
940
+ default_urls = []
941
 
942
  # Reset to original default values
943
  updates = [
944
+ "Reset to default values",
 
 
945
  gr.update(value=DEFAULT_CONFIG.get('system_prompt', SYSTEM_PROMPT)),
946
  gr.update(value=DEFAULT_CONFIG.get('model', MODEL)),
947
  gr.update(value=examples_text),
948
+ gr.update(value=DEFAULT_CONFIG.get('temperature', temperature)),
949
+ gr.update(value=DEFAULT_CONFIG.get('max_tokens', max_tokens))
950
  ]
951
 
952
  # Add URL updates
 
956
 
957
  return tuple(updates)
958
 
 
 
 
 
 
 
 
 
 
 
 
 
959
 
960
  # Connect configuration buttons
961
  save_config_btn.click(
962
  save_configuration,
963
+ inputs=[faculty_auth_state, edit_system_prompt, edit_model, edit_examples, edit_temperature, edit_max_tokens] + url_fields + [config_locked],
964
  outputs=[config_status]
965
  )
966
 
967
  reset_config_btn.click(
968
  reset_configuration,
969
  inputs=[faculty_auth_state],
970
+ outputs=[config_status, edit_system_prompt, edit_model, edit_examples, edit_temperature, edit_max_tokens] + url_fields
971
  )
 
 
972
 
973
  if __name__ == "__main__":
974
  demo.launch()