File size: 29,001 Bytes
2139cb3
dbb3f80
2139cb3
 
 
 
 
dbb3f80
 
 
 
 
 
61cb8cf
dbb3f80
 
 
 
2139cb3
 
 
 
61cb8cf
dbb3f80
61cb8cf
 
 
 
 
2139cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61cb8cf
dbb3f80
 
 
 
 
 
 
 
 
 
 
61cb8cf
dbb3f80
61cb8cf
dbb3f80
2139cb3
 
 
 
 
 
 
 
 
 
dbb3f80
2139cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbb3f80
 
61cb8cf
 
 
 
 
 
 
 
 
 
 
 
 
dbb3f80
61cb8cf
 
dbb3f80
2139cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbb3f80
 
 
 
61cb8cf
dbb3f80
61cb8cf
dbb3f80
 
 
2139cb3
 
 
 
dbb3f80
 
 
 
 
 
 
 
2139cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbb3f80
61cb8cf
 
 
 
 
 
 
 
 
 
 
2139cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61cb8cf
2139cb3
61cb8cf
 
 
 
 
2139cb3
 
 
 
 
 
61cb8cf
 
 
 
 
2139cb3
 
dbb3f80
 
61cb8cf
dbb3f80
 
61cb8cf
dbb3f80
 
 
 
 
 
 
61cb8cf
7eaf569
2139cb3
dbb3f80
 
 
61cb8cf
2139cb3
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478

import os
import json
import logging
import random
import re
from langchain_core.messages import SystemMessage, HumanMessage  #
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import PyPDFDirectoryLoader, WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_tavily import TavilySearch
from langchain.tools.retriever import create_retriever_tool
from langchain.agents import create_tool_calling_agent, AgentExecutor
import gradio as gr
import numpy as np
from dataclasses import dataclass
from typing import List, Dict
import uuid
import socket
from pythia import scenario_simulator

# Set up logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)

# Load environment variables
os.environ['USER_AGENT'] = os.getenv('USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')

# load_dotenv()
# Verify API keys
openai_api_key = os.getenv("OPENAI_API_KEY")
tavily_api_key = os.getenv("TAVILY_API_KEY")
if not openai_api_key or not tavily_api_key:
    logger.error("Missing API keys: OPENAI_API_KEY or TAVILY_API_KEY")
    raise ValueError("API keys not set in environment")

# Persona-related definitions
@dataclass
class Persona:
    id: str
    name: str
    age: int
    gender: str
    occupation: str
    adoption_stage: str
    pain_points: List[str]
    motivations: List[str]
    demographics: Dict

# Store active persona and conversation state
active_persona = None
conversation_count = 0
MAX_QUESTIONS = 20



def load_web_resources(file='websites.txt'):
    urls = ['https://steveblank.com/']
    if os.path.exists(file):
        with open(file, 'r') as f:
            urls.extend(line.strip() for line in f if line.strip())
    web_docs = []
    for url in urls:
        try:
            loader = WebBaseLoader(url)
            web_docs.extend(loader.load())
            logger.info(f"Loaded web resources from {url}")
        except Exception as e:
            logger.error(f"Failed to load {url}: {e}")
    return web_docs
def persona_generator(user_input: str) -> List[Dict]:
    """Generates five UX personas based on user input and adoption stages."""
    try:
        llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
        
        # Always pull Dr. Sue's material for context
        retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
        docs = retriever.invoke(user_input)
        dr_sue_context = "\n\n".join([doc.page_content for doc in docs])
        logger.info(f"Incorporated Dr. Sue context: {dr_sue_context[:200]}...")

        # Build messages directly
        system_content = f"""
You are a UX persona generator for Dr. Sue Cohen's Commercializing New Technologies class. Parse the user input (which may be narrative, e.g., 'Single mother, tired') into a JSON list of exactly 5 personas, one for each adoption stage: Innovator, Early Adopter, Early Majority, Late Majority, Laggard. Output ONLY valid JSON matching the template below—do NOT add extra text or prose.

Incorporate all input details (e.g., occupation, age, gender, family status, pain points). Convert narrative phrases into concise pain_points and motivations (e.g., 'She is tired' → 'Shift-induced exhaustion'). Use input pain points if provided (e.g., 'perpetually tired' → 'Shift-induced exhaustion'); otherwise, generate stage-specific ones consistent with the input context. Generate unique motivations and scenarios per persona, reflecting their adoption stage and healthcare technology adoption (e.g., AI tools, telehealth). Scenarios show tech use, tied to stage, pain points, and motivations. Include demographics (location, family_status, workplace). Generate unique UUID for 'id' and unique realistic names. Use this course context for depth (e.g., regulatory barriers, scaling): {dr_sue_context}

JSON template:
[
  {{
    "id": "UUID",
    "name": "Unique Name",
    "age": integer,
    "gender": "Gender",
    "occupation": "Occupation",
    "adoption_stage": "Stage",
    "pain_points": ["Phrase1", "Phrase2", "Phrase3"],
    "motivations": ["Phrase1", "Phrase2", "Phrase3"],
    "demographics": {{"location": "Place", "family_status": "Status", "workplace": "Place"}},
    "scenarios": ["Narrative1", "Narrative2"]
  }}
]

Adoption stage traits:
  - Innovators: Risk-takers, tech enthusiasts, embrace new tools.
  - Early Adopters: Visionaries, influence others, accept moderate risk.
  - Early Majority: Pragmatic, seek proven value, efficiency-focused.
  - Late Majority: Skeptical, need social proof, risk-averse.
  - Laggards: Resistant, distrust change, adopt last.
        """.strip()
        
        messages = [
            SystemMessage(content=system_content),
            HumanMessage(content=user_input)
        ]
        
        logger.info(f"Invoking LLM with input: {user_input}")
        response = llm.invoke(messages)
        logger.info(f"LLM response: {response.content[:200]}...")
        personas = json.loads(response.content)
        
        # Validation
        if not isinstance(personas, list) or len(personas) != 5:
            raise ValueError(f"Expected 5 personas, got {len(personas)}")
        adoption_stages = ["Innovator", "Early Adopter", "Early Majority", "Late Majority", "Laggard"]
        for i, persona in enumerate(personas):
            if "id" not in persona or not persona["id"]:
                persona["id"] = str(uuid.uuid4())
            if "adoption_stage" not in persona or persona["adoption_stage"] not in adoption_stages:
                persona["adoption_stage"] = adoption_stages[i]
            required_fields = ["name", "age", "gender", "occupation", "pain_points", "motivations", "demographics", "scenarios"]
            for field in required_fields:
                if field not in persona:
                    raise ValueError(f"Missing field {field} in persona {i}")
            if not isinstance(persona["age"], int):
                try:
                    persona["age"] = int(persona["age"])
                except (ValueError, TypeError):
                    raise ValueError("Invalid age")
            if not isinstance(persona["pain_points"], list) or not all(isinstance(p, str) for p in persona["pain_points"]):
                raise ValueError(f"Invalid pain_points in persona {i}")
            if not isinstance(persona["motivations"], list) or not all(isinstance(p, str) for p in persona["motivations"]):
                raise ValueError(f"Invalid motivations in persona {i}")
            if not isinstance(persona["scenarios"], list) or not all(isinstance(s, str) for s in persona["scenarios"]):
                raise ValueError(f"Invalid scenarios in persona {i}")
            if not isinstance(persona["demographics"], dict) or not all(k in persona["demographics"] for k in ["location", "family_status", "workplace"]):
                raise ValueError(f"Invalid demographics in persona {i}")
        logger.info(f"Successfully generated {len(personas)} personas")
        return personas
    except Exception as e:
        logger.error(f"Persona generation failed: {str(e)}")
        raise ValueError(f"Failed to generate personas: {str(e)}. Use format: 'Persona Mode: Generate Persona: [description]'")
def build_vectorstore(pdf_dir="ConvertedFiles"):
    try:
        loader = PyPDFDirectoryLoader(pdf_dir)
        docs = loader.load()
        logger.info(f"Loaded {len(docs)} documents from {pdf_dir}")
        web_docs = load_web_resources()
        all_docs = docs + web_docs
        splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
        splits = splitter.split_documents(all_docs)
        logger.info(f"Split into {len(splits)} document chunks")
        embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        vectorstore = FAISS.from_documents(splits, embeddings)
        vectorstore.save_local("faiss_index")
        logger.info("Saved FAISS index to faiss_index/")
        return vectorstore
    except Exception as e:
        logger.error(f"Failed to build vectorstore: {e}")
        raise


    """Generates five UX personas based on user input and adoption stages."""
    try:
        llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
        
        # Always pull Dr. Sue's material for context
        retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
        docs = retriever.invoke(user_input)
        dr_sue_context = "\n\n".join([doc.page_content for doc in docs])
        logger.info(f"Incorporated Dr. Sue context: {dr_sue_context[:200]}...")

        # Build messages directly
        system_content = f"""
You are a UX persona generator for Dr. Sue Cohen's Commercializing New Technologies class. Parse the user input (which may be narrative, e.g., 'Single mother, tired') into a JSON list of exactly 5 personas, one for each adoption stage: Innovator, Early Adopter, Early Majority, Late Majority, Laggard. Output ONLY valid JSON matching the template below—do NOT add extra text or prose.

Incorporate all input details (e.g., occupation, age, gender, family status, pain points). Convert narrative phrases into concise pain_points and motivations (e.g., 'She is tired' → 'Shift-induced exhaustion'). Use input pain points if provided; otherwise, generate stage-specific ones consistent with the input context. Generate unique motivations and scenarios per persona, reflecting their adoption stage and healthcare technology adoption (e.g., AI tools, telehealth). Scenarios show tech use, tied to stage, pain points, and motivations. Include demographics (location, family_status, workplace). Generate unique UUID for 'id' and unique realistic names. Use this course context for depth (e.g., regulatory barriers, scaling): {dr_sue_context}

JSON template:
[
  {{
    "id": "UUID",
    "name": "Unique Name",
    "age": integer,
    "gender": "Gender",
    "occupation": "Occupation",
    "adoption_stage": "Stage",
    "pain_points": ["Phrase1", "Phrase2", "Phrase3"],
    "motivations": ["Phrase1", "Phrase2", "Phrase3"],
    "demographics": {{"location": "Place", "family_status": "Status", "workplace": "Place"}},
    "scenarios": ["Narrative1", "Narrative2"]
  }}
]

Adoption stage traits:
  - Innovators: Risk-takers, tech enthusiasts, embrace new tools.
  - Early Adopters: Visionaries, influence others, accept moderate risk.
  - Early Majority: Pragmatic, seek proven value, efficiency-focused.
  - Late Majority: Skeptical, need social proof, risk-averse.
  - Laggards: Resistant, distrust change, adopt last.
        """.strip()
        
        messages = [
            SystemMessage(content=system_content),
            HumanMessage(content=user_input)
        ]
        
        logger.info(f"Invoking LLM with input: {user_input}")
        response = llm.invoke(messages)
        logger.info(f"LLM response: {response.content[:200]}...")
        personas = json.loads(response.content)
        
        # Validation
        if not isinstance(personas, list) or len(personas) != 5:
            raise ValueError(f"Expected 5 personas, got {len(personas)}")
        adoption_stages = ["Innovator", "Early Adopter", "Early Majority", "Late Majority", "Laggard"]
        for i, persona in enumerate(personas):
            if "id" not in persona or not persona["id"]:
                persona["id"] = str(uuid.uuid4())
            if "adoption_stage" not in persona or persona["adoption_stage"] not in adoption_stages:
                persona["adoption_stage"] = adoption_stages[i]
            required_fields = ["name", "age", "gender", "occupation", "pain_points", "motivations", "demographics", "scenarios"]
            for field in required_fields:
                if field not in persona:
                    raise ValueError(f"Missing field {field} in persona {i}")
            if not isinstance(persona["age"], int):
                try:
                    persona["age"] = int(persona["age"])
                except (ValueError, TypeError):
                    raise ValueError("Invalid age")
            if not isinstance(persona["pain_points"], list) or not all(isinstance(p, str) for p in persona["pain_points"]):
                raise ValueError(f"Invalid pain_points in persona {i}")
            if not isinstance(persona["motivations"], list) or not all(isinstance(p, str) for p in persona["motivations"]):
                raise ValueError(f"Invalid motivations in persona {i}")
            if not isinstance(persona["scenarios"], list) or not all(isinstance(s, str) for s in persona["scenarios"]):
                raise ValueError(f"Invalid scenarios in persona {i}")
            if not isinstance(persona["demographics"], dict) or not all(k in persona["demographics"] for k in ["location", "family_status", "workplace"]):
                raise ValueError(f"Invalid demographics in persona {i}")
        logger.info(f"Successfully generated {len(personas)} personas")
        return personas
    except Exception as e:
        logger.error(f"Persona generation failed: {str(e)}")
        raise ValueError(f"Failed to generate personas: {str(e)}. Use format: 'Persona Mode: Generate Persona: [description]'")
def validate_persona_json(persona_json: str) -> Dict:
    """Validates and parses a persona JSON string."""
    try:
        # Clean JSON input to handle whitespace and line breaks
        persona_json = ' '.join(persona_json.strip().split())
        persona = json.loads(persona_json)
        required_fields = ["id", "name", "age", "gender", "occupation", "adoption_stage", "pain_points", "motivations", "demographics", "scenarios"]
        for field in required_fields:
            if field not in persona:
                raise ValueError(f"Missing field: {field}")
        if not isinstance(persona["age"], int):
            try:
                persona["age"] = int(persona["age"])
            except (ValueError, TypeError):
                raise ValueError("age must be an integer")
        if not isinstance(persona["pain_points"], list) or not all(isinstance(p, str) for p in persona["pain_points"]):
            raise ValueError("pain_points must be a list of strings")
        if not isinstance(persona["motivations"], list) or not all(isinstance(p, str) for p in persona["motivations"]):
            raise ValueError("motivations must be a list of strings")
        if not isinstance(persona["scenarios"], list) or not all(isinstance(s, str) for s in persona["scenarios"]):
            raise ValueError("scenarios must be a list of strings")
        if not isinstance(persona["demographics"], dict) or not all(k in persona["demographics"] for k in ["location", "family_status", "workplace"]):
            raise ValueError("demographics must be a dict with location, family_status, workplace")
        # Normalize adoption_stage
        adoption_stages = ["Innovator", "Early Adopter", "Early Majority", "Late Majority", "Laggard"]
        adoption_stage = persona["adoption_stage"].title()
        if adoption_stage not in adoption_stages:
            raise ValueError(f"Invalid adoption stage: {persona['adoption_stage']}")
        persona["adoption_stage"] = adoption_stage
        # Fix gender inconsistencies
        family_status = persona["demographics"]["family_status"].lower()
        if "mother" in family_status and persona["gender"].lower() != "female":
            persona["gender"] = "Female"
        elif "father" in family_status and persona["gender"].lower() != "male":
            persona["gender"] = "Male"
        # Clean family_status
        persona["demographics"]["family_status"] = re.sub(r'^(she|he|they)\s+', '', family_status, flags=re.IGNORECASE).strip().title()
        logger.info(f"Validated persona: {persona['name']} ({persona['adoption_stage']})")
        return persona
    except json.JSONDecodeError as e:
        logger.error(f"JSON parsing error: {str(e)}")
        return None
    except Exception as e:
        logger.error(f"Invalid persona JSON: {str(e)}")
        return None

def setup_agent(vectorstore):
    retriever_tool = create_retriever_tool(
        vectorstore.as_retriever(search_kwargs={"k": 5}),
        "course_materials_retriever",
        "Searches course materials on optimization, simulation, decision tech, project management, and lean startup."
    )
    web_tool = TavilySearch(max_results=3)
    tools = [retriever_tool, web_tool, scenario_simulator]
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
    prompt = ChatPromptTemplate.from_messages([
        ("system", """
        You are Dr. Sue Cohen, a passionate professor of Commercializing New Technologies, unless a persona is active, in which case you respond fully in-character as the persona. Answer in a witty, professional, detailed style (150-250 words for course content to ensure depth with examples and in-depth analysis), using main points from diverse course materials without citing file names in the main response. Avoid over-relying on any single lecture (e.g., Lecture 5). Use Tavily/web for recent updates in separate paragraphs with up to 5 URLs only if relevant and available; if not, omit the web section. Don’t mix course and web content. Run scenario sims for adoption/scalability with clear params. If a persona is active, respond only as that persona, staying true to their profile. Redirect off-topic queries, prompt next steps with a sharp question.
        Active Persona (if any): {active_persona}
        """),
        ("human", "{input}"),
        MessagesPlaceholder("agent_scratchpad")
    ])
    agent = create_tool_calling_agent(llm, tools, prompt)
    executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
    return executor

def chat(message, history):
    global active_persona, conversation_count
    logger.info(f"Received message: {message}")
    
    # Parse Persona Mode commands
    if message.lower().startswith("persona mode:"):
        command = message[len("persona mode:"):].strip().lower()
        logger.info(f"Processing persona command: {command}")
        
        if command.startswith("generate persona:"):
            try:
                user_input = command[len("generate persona:"):].strip()
                logger.info(f"Processing generate persona with input: {user_input}")
                personas = persona_generator(user_input)
                logger.info(f"Generated personas: {[p['id'] + ': ' + p['name'] for p in personas]}")
                return json.dumps(personas, indent=2) + "\n\nCopy the JSON above and save to a .json file for reuse. Use 'Persona Mode: Activate Persona: [JSON]' to interact with a persona."
            except Exception as e:
                logger.error(f"Persona generation failed in chat: {e}")
                return f"Error: {e}. Use format: 'Persona Mode: Generate Persona: [description]'"
        
        elif command.startswith("activate persona:"):
            try:
                persona_json = command[len("activate persona:"):].strip()
                persona = validate_persona_json(persona_json)
                if persona:
                    active_persona = persona
                    conversation_count = 0
                    # Set pronouns based on gender
                    pronouns = {
                        "Male": {"subject": "he", "possessive": "his"},
                        "Female": {"subject": "she", "possessive": "her"},
                        "Non-binary": {"subject": "they", "possessive": "their"}
                    }.get(persona["gender"].title(), {"subject": "they", "possessive": "their"})
                    # Clean fields for summary
                    pain_point_1 = re.sub(r'^(she|he|they)\s+', '', persona['pain_points'][0], flags=re.IGNORECASE).strip().lower()
                    pain_point_2 = re.sub(r'^(she|he|they)\s+', '', persona['pain_points'][1], flags=re.IGNORECASE).strip().lower() if len(persona['pain_points']) > 1 else "workplace challenges"
                    motivation_1 = re.sub(r'^(she|he|they)\s+', '', persona['motivations'][0], flags=re.IGNORECASE).strip().lower()
                    motivation_2 = re.sub(r'^(she|he|they)\s+', '', persona['motivations'][1], flags=re.IGNORECASE).strip().lower() if len(persona['motivations']) > 1 else "professional growth"
                    scenario_1 = re.sub(r'^(she|he|they)\s+', '', persona['scenarios'][0], flags=re.IGNORECASE).strip()
                    scenario_2 = re.sub(r'^(she|he|they)\s+', '', persona['scenarios'][1], flags=re.IGNORECASE).strip() if len(persona['scenarios']) > 1 else f"Evaluates tech to support {motivation_1}."
                    # Generate summary in Dr. Sue's voice
                    summary = f"""
{persona['name']} is a quintessential {persona['adoption_stage'].lower()} in the wild world of healthcare commercialization, tackling innovation at {persona['demographics']['workplace'].lower()}. {pronouns['subject'].title()}'s passion for {motivation_1} drives {pronouns['subject']} to size up healthcare technologies, but {pain_point_1} keeps {pronouns['subject']} on edge. {scenario_1} Meanwhile, {pronouns['subject']} grapples with {pain_point_2}, a stark reminder of the stakes in {persona['demographics']['location'].lower()}. Yet, {pronouns['subject']}'s commitment to {motivation_2} fuels {pronouns['possessive']} resolve, juggling {persona['demographics']['family_status'].lower()} alongside {pronouns['possessive']} career. In our course's spirit, {pronouns['subject']}'s a case study in navigating adoption barriers—think regulatory hoops or staff pushback—demanding a pitch sharper than a scalpel. To win {pronouns['subject']} over, you'll need rock-solid data and a plan to scale without derailing {pronouns['possessive']} hectic life. What's your strategy to crack {pronouns['possessive']} skepticism?
                    """
                    logger.info(f"Activated persona: {persona['name']} ({persona['adoption_stage']})")
                    return f"Activated persona: {persona['name']} ({persona['adoption_stage']}). Ask up to {MAX_QUESTIONS} questions.\n\n{summary.strip()}"
                return f"Error: Invalid persona JSON. Ensure all required fields are included and JSON is valid. Example: {{'id': 'uuid', 'name': 'Name', 'age': 35, 'gender': 'Male', 'occupation': 'Doctor', 'adoption_stage': 'Innovator', 'pain_points': ['issue1', 'issue2'], 'motivations': ['goal1', 'goal2'], 'demographics': {{'location': 'City', 'family_status': 'Status', 'workplace': 'Place'}}, 'scenarios': ['Story1', 'Story2']}}"
            except Exception as e:
                logger.error(f"Persona activation failed: {e}")
                return f"Error: {e}. Use format: 'Persona Mode: Activate Persona: [JSON]'"
        
        elif command == "end conversation":
            if active_persona:
                name = active_persona['name']
                active_persona = None
                conversation_count = 0
                logger.info(f"Ended conversation with {name}")
                return f"Ended conversation with {name}. Now in Dr. Sue Cohen's TA mode. What's your next question?"
            logger.warning("No active persona conversation to end")
            return "No active persona conversation to end."
        
        else:
            logger.error(f"Invalid persona command: {command}")
            return f"Invalid Persona Mode command: {command}. Use 'Generate Persona: [description]', 'Activate Persona: [JSON]', or 'End Conversation'."

    # Parse scenario simulator query
    if "initial_users" in message.lower():
        params = {}
        try:
            for pair in message.split(","):
                key, value = pair.split(":")
                key = key.strip()
                value = float(value.strip().replace("%", "")) / 100 if "%" in value else int(value.strip())
                params[key] = value
            logger.info(f"Running scenario simulator with params: {params}")
            return scenario_simulator(params)
        except Exception as e:
            logger.error(f"Failed to parse scenario params: {e}")
            return f"Error parsing params: {e}. Format: initial_users:1000, growth_rate:5%, time_months:12, uncertainty:10%"

    # Handle persona interaction
    if active_persona and conversation_count < MAX_QUESTIONS:
        try:
            llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
            messages = [
                ("system", f"""
                You are {active_persona['name']}, a {active_persona['age']}-year-old {active_persona['gender']} {active_persona['occupation']}.
                Adoption stage: {active_persona['adoption_stage']}.
                Personality traits: {active_persona['adoption_stage'].lower()} (trailblazers for Innovators, visionaries for Early Adopters, pragmatic for Early Majority, skeptical for Late Majority, change-resistant for Laggards).
                Pain points: {', '.join(active_persona['pain_points'])}.
                Motivations: {', '.join(active_persona['motivations'])}.
                Demographics: {json.dumps(active_persona['demographics'])}.
                You are being interviewed about a healthcare technology (e.g., AI patient monitoring tool) as a potential customer or stakeholder. Respond in character, using first-person voice, in 3-5 detailed sentences. Draw on your pain points (e.g., regulatory barriers, staff resistance from course materials), motivations (e.g., evidence-based validation for scaling), demographics (e.g., work-life balance in Pittsburgh), and scenarios (e.g., overcoming adoption hurdles like IT integration). Use conversational, realistic language with specific examples from healthcare commercialization (e.g., pilot studies, board approvals), avoiding medical jargon unless relevant to your role. Do NOT ask a question back to the interviewer; provide a complete, standalone response to simulate a realistic client interview for student training.
                Question: {message}
                """),
                ("human", message)
            ]
            response = llm.invoke(messages)
            conversation_count += 1
            remaining = MAX_QUESTIONS - conversation_count
            logger.info(f"Persona response generated for {active_persona['name']}, {remaining} questions left")
            return f"{response.content}\n\n({remaining} questions left. Use 'Persona Mode: End Conversation' to stop.)"
        except Exception as e:
            logger.error(f"Persona interaction failed: {e}")
            return f"Error: {e}. Try again or end conversation."

    # Handle regular RAG queries
    retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
    docs = retriever.invoke(message)
    context = "\n\n".join([doc.page_content for doc in docs])
    titles = [os.path.basename(doc.metadata.get('source', 'Unknown')) for doc in docs]
    logger.info(f"Retrieved {len(docs)} documents for query: {message}")
    
    try:
        web_results = TavilySearch(max_results=5).invoke(message)
        if isinstance(web_results, str):
            web_context = f"Web: {web_results[:150]}..."
        else:
            web_context = "\n".join([f"Web: {res.get('title', 'Untitled')} - {res.get('content', '')[:150]}... (URL: {res.get('url', 'Unknown')})" for res in web_results])
        logger.info(f"Retrieved {len(web_results) if isinstance(web_results, list) else 1} web results from Tavily")
    except Exception as e:
        web_context = ""
        logger.error(f"Tavily search failed: {e}")
    
    input_with_context = f"{message}\n\nCourse Materials:\n{context}\n\nWeb Updates:\n{web_context}\n\nPDF References:\n{', '.join(titles)}"
    logger.info(f"Processing RAG query: {message}")
    return agent.invoke({"input": input_with_context, "active_persona": active_persona or "None"})["output"]

if not os.path.exists("faiss_index"):
    logger.warning("FAISS index not found, rebuilding...")
    vectorstore = build_vectorstore()
else:
    logger.info("Loading prebuilt FAISS index...")
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = FAISS.load_local("faiss_index", embeddings, allow_dangerous_deserialization=True)

agent = setup_agent(vectorstore)

demo = gr.ChatInterface(
    fn=chat,
    chatbot=gr.Chatbot(type='messages'),
    title="COMMERCIALIZING NEW TECHNOLOGIES RAG Bot",
    description="Query course materials, run scenario simulations, or use Persona Mode: 'Generate Persona: [description]', 'Activate Persona: [JSON]', 'End Conversation'"
)

if __name__ == "__main__":
    logger.info("Starting Gradio app...")
    try:
        
        logger.info(f"Using port: {7860}")
        demo.launch(server_name="0.0.0.0", server_port=7860)
    except Exception as e:
        logger.error(f"Failed to start Gradio app: {e}")
        raise