| import streamlit as st |
| import time |
| from openai import OpenAI |
| import os |
| from dotenv import load_dotenv |
| import pandas as pd |
| import io |
| import re |
|
|
| |
| load_dotenv() |
|
|
| |
| st.set_page_config( |
| page_title="Creative Professionals Job Analysis", |
| page_icon="🎨", |
| layout="wide" |
| ) |
|
|
| |
| def check_password(): |
| correct_password = None |
| try: |
| if "app_password" in st.secrets: |
| correct_password = st.secrets["app_password"] |
| except: |
| pass |
| if not correct_password: |
| correct_password = os.environ.get("APP_PASSWORD") or os.getenv("APP_PASSWORD") |
| if not correct_password: |
| correct_password = "cpro2024" |
|
|
| def password_entered(): |
| entered_password = st.session_state.get("password", "") |
| if entered_password == correct_password: |
| st.session_state["password_correct"] = True |
| if "password" in st.session_state: |
| del st.session_state["password"] |
| else: |
| st.session_state["password_correct"] = False |
|
|
| if st.session_state.get("password_correct", False): |
| return True |
|
|
| st.markdown("## 🔒 Authentication Required") |
| st.text_input( |
| "Please enter the password to access this app:", |
| type="password", |
| on_change=password_entered, |
| key="password" |
| ) |
|
|
| if "password_correct" in st.session_state and not st.session_state["password_correct"]: |
| st.error("😕 Password incorrect. Please try again.") |
| return False |
|
|
| if not check_password(): |
| st.stop() |
|
|
| |
| st.markdown(""" |
| <style> |
| .stButton > button { |
| background-color: #B19CD9; |
| color: white; |
| border-radius: 5px; |
| } |
| .stButton > button:hover { |
| background-color: #9B84C9; |
| color: white; |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| st.title("🎨 Creative Professionals Job Listings Analysis") |
|
|
| |
| with st.sidebar: |
|
|
| |
| if st.button("🗑️ Clear Conversation", use_container_width=True): |
| st.session_state.messages = [] |
| st.session_state.thread_id = None |
| st.rerun() |
|
|
| api_key = st.secrets.get("openai_api_key", os.getenv("OPENAI_API_KEY", "")) |
| assistant_id = "asst_vaqQlpvneKehFNBtk8LOIj0A" |
|
|
| if not api_key: |
| st.error("⚠️ OpenAI API key not configured.") |
| st.stop() |
|
|
| st.divider() |
| st.header("Select Analysis Method") |
|
|
| mode = st.radio( |
| "Choose method:", |
| ["Code Interpreter (CSV)", "Semantic Search", "Hybrid"], |
| index=0 |
| ) |
|
|
| mode_prefix = { |
| "Code Interpreter (CSV)": "/csv", |
| "Semantic Search": "/semantic", |
| "Hybrid": "/hybrid" |
| }[mode] |
|
|
| |
| st.subheader(f"About the {mode} method:") |
|
|
| if mode == "Code Interpreter (CSV)": |
| st.markdown(""" |
| Use this for precise, structured questions. The assistant loads the CSV of job listings, |
| runs Python, and filters or aggregates the data. |
| |
| **Examples:** |
| - *“List all job titles of Business Professionals.”* |
| - *“Count the number of Design Pro listings.”* |
| - *“Show me all job titles that mention Figma and are classified as Design Pro.”* |
| |
| **Best for:** |
| - Exact counts |
| - Filtering by category |
| - CSV-driven analysis |
| """) |
|
|
| elif mode == "Semantic Search": |
| st.markdown(""" |
| Use this for interpretive, conceptual, or text-heavy questions. The assistant retrieves |
| text from job descriptions using vector search. |
| |
| **Examples:** |
| - *“What are common responsibilities of Business Professionals?”* |
| - *“Which job descriptions mention generative AI skills?”* |
| - *“Summarize trends in photography-related jobs.”* |
| |
| **Best for:** |
| - Thematic summaries |
| - Conceptual trends |
| - Free-text exploration |
| """) |
|
|
| else: |
| st.markdown(""" |
| Use this when you want both semantic retrieval and structured filtering. |
| The assistant mixes judgement and rigorous filtering. |
| |
| **Examples:** |
| - *“Find jobs mentioning TikTok and report their creative category.”* |
| - *“Identify job descriptions referencing UX research and list their job titles.”* |
| |
| **Best for:** |
| - Hybrid text + structured queries |
| - Context-driven filtering |
| """) |
|
|
| st.divider() |
|
|
| |
| if "messages" not in st.session_state: |
| st.session_state.messages = [] |
| if "thread_id" not in st.session_state: |
| st.session_state.thread_id = None |
|
|
| client = OpenAI(api_key=api_key) |
|
|
| def is_mode_detection_failure(response_text): |
| """ |
| Check if the response indicates the assistant failed to detect the mode prefix. |
| This happens when the assistant asks for mode specification despite already having one. |
| """ |
| if not response_text: |
| return False |
|
|
| failure_phrases = [ |
| "please specify /csv", |
| "please specify a mode", |
| "specify /csv, /semantic, or /hybrid", |
| "/csv, /semantic, or /hybrid mode", |
| "which mode would you like" |
| ] |
| response_lower = response_text.lower() |
| return any(phrase in response_lower for phrase in failure_phrases) |
|
|
| def clean_response_text(text): |
| """ |
| Remove source citations and sandbox links from response text. |
| These citations reference internal files that cannot be downloaded. |
| """ |
| if not text: |
| return "" |
|
|
| |
| text = re.sub(r'【[^】]*】', '', text) |
|
|
| |
| text = re.sub(r'\[.*?\]\(sandbox:/[^\)]*\)', '', text) |
| text = re.sub(r'\[Download.*?\]\(/mnt/data/.*?\)', '', text) |
| text = re.sub(r'sandbox:/[^\s\)]+', '', text) |
|
|
| |
| text = re.sub(r'Source:\s*[^\n]*?【[^】]*】\s*\.', '', text) |
| text = re.sub(r'^\s*[○◦•]\s*Source:.*$', '', text, flags=re.MULTILINE) |
|
|
| |
| text = re.sub(r'\n\s*\n\s*\n', '\n\n', text) |
|
|
| return text.strip() |
|
|
| def extract_table_from_response(response_text): |
| """ |
| Extract tabular data from assistant's response. |
| Looks for markdown tables or numbered lists that can be converted to CSV. |
| """ |
| lines = response_text.split('\n') |
| data_rows = [] |
|
|
| |
| in_table = False |
| headers = None |
|
|
| for line in lines: |
| line = line.strip() |
| if '|' in line and not line.startswith('---'): |
| if headers is None: |
| |
| headers = [h.strip() for h in line.split('|') if h.strip()] |
| in_table = True |
| elif in_table: |
| |
| row = [cell.strip() for cell in line.split('|') if cell.strip()] |
| if row and len(row) == len(headers): |
| data_rows.append(row) |
| elif in_table and not line: |
| |
| break |
|
|
| |
| if headers and data_rows: |
| return pd.DataFrame(data_rows, columns=headers) |
|
|
| |
| pattern = r'^\d+\.\s+(.+?)\s+[-–]\s+(\d+)' |
| for line in lines: |
| match = re.match(pattern, line.strip()) |
| if match: |
| data_rows.append([match.group(1).strip(), match.group(2).strip()]) |
|
|
| if data_rows: |
| return pd.DataFrame(data_rows, columns=['Item', 'Count']) |
|
|
| return None |
|
|
| def query_assistant(question, api_key, assistant_id, thread_id=None, timeout=600): |
| client = OpenAI(api_key=api_key) |
| try: |
| if thread_id: |
| thread = client.beta.threads.retrieve(thread_id) |
| else: |
| thread = client.beta.threads.create() |
| thread_id = thread.id |
|
|
| client.beta.threads.messages.create( |
| thread_id=thread_id, |
| role="user", |
| content=question |
| ) |
|
|
| run = client.beta.threads.runs.create( |
| thread_id=thread_id, |
| assistant_id=assistant_id, |
| temperature=0.1, |
| top_p=1.0, |
| timeout=timeout |
| ) |
|
|
| start = time.time() |
| while run.status in ['queued', 'in_progress', 'cancelling']: |
| time.sleep(1) |
| run = client.beta.threads.runs.retrieve( |
| thread_id=thread_id, run_id=run.id |
| ) |
| if time.time() - start > timeout: |
| client.beta.threads.runs.cancel( |
| thread_id=thread_id, run_id=run.id |
| ) |
| return "Query timed out.", thread_id, run.id, [] |
|
|
| if run.status == "completed": |
| messages = client.beta.threads.messages.list( |
| thread_id=thread_id, order='desc', limit=10 |
| ) |
| response_parts = [] |
| file_ids = [] |
|
|
| for msg in messages.data: |
| if msg.role == "assistant": |
| for c in msg.content: |
| if c.type == "text": |
| response_parts.append(c.text.value) |
|
|
| |
| |
| |
| if hasattr(c.text, 'annotations'): |
| for annotation in c.text.annotations: |
| if hasattr(annotation, 'file_path'): |
| |
| file_ids.append(("file", annotation.file_path.file_id)) |
| |
| |
| |
|
|
| elif c.type == "image_file": |
| file_ids.append(("image", c.image_file.file_id)) |
|
|
| |
| if hasattr(msg, 'attachments') and msg.attachments: |
| for attachment in msg.attachments: |
| if hasattr(attachment, 'file_id'): |
| file_ids.append(("file", attachment.file_id)) |
| else: |
| break |
|
|
| return "\n\n".join(reversed(response_parts)), thread_id, run.id, file_ids |
|
|
| return f"Unexpected status: {run.status}", thread_id, run.id, [] |
|
|
| except Exception as e: |
| return f"Error: {str(e)}", thread_id, None, [] |
|
|
| |
| for m in st.session_state.messages: |
| with st.chat_message(m["role"]): |
| st.markdown(m["content"]) |
|
|
| |
| user_input = st.chat_input("Ask about job listings...") |
|
|
| if user_input: |
| prefixed_query = f"{mode_prefix} {user_input}" |
|
|
| with st.chat_message("user"): |
| st.markdown(user_input) |
|
|
| st.session_state.messages.append({"role": "user", "content": user_input}) |
|
|
| with st.chat_message("assistant"): |
| |
| max_retries = 3 |
| retry_count = 0 |
| response = None |
| thread_id = st.session_state.thread_id |
| file_ids = [] |
|
|
| while retry_count <= max_retries: |
| with st.spinner("Analyzing..." if retry_count == 0 else f"Retrying ({retry_count}/{max_retries})..."): |
| response, thread_id, run_id, file_ids = query_assistant( |
| prefixed_query, api_key, assistant_id, thread_id=None |
| ) |
|
|
| |
| if is_mode_detection_failure(response) and retry_count < max_retries: |
| retry_count += 1 |
| st.warning(f"Mode detection issue detected. Retrying... ({retry_count}/{max_retries})") |
| time.sleep(1) |
| else: |
| break |
|
|
| |
| if retry_count > 0 and not is_mode_detection_failure(response): |
| st.info(f"Query succeeded after {retry_count} retry(ies).") |
|
|
| |
| if is_mode_detection_failure(response): |
| st.error( |
| f"The assistant is having trouble processing this query in **{mode}** mode. " |
| f"Please try one of these options:\n" |
| f"- Switch to a different analysis method in the sidebar\n" |
| f"- Rephrase your question\n" |
| f"- Try again in a few moments" |
| ) |
|
|
| |
| cleaned_response = clean_response_text(response) |
| st.markdown(cleaned_response) |
|
|
| |
| csv_requested = any(keyword in user_input.lower() for keyword in ['csv', 'download', 'export', 'file']) |
|
|
| |
| if csv_requested: |
| df_extracted = extract_table_from_response(cleaned_response) |
| if df_extracted is not None and len(df_extracted) > 0: |
| |
| csv_buffer = io.StringIO() |
| df_extracted.to_csv(csv_buffer, index=False) |
| csv_data = csv_buffer.getvalue() |
|
|
| |
| st.success(f"✅ Generated CSV with {len(df_extracted)} rows") |
| st.download_button( |
| label="📥 Download CSV File", |
| data=csv_data, |
| file_name="job_listings_export.csv", |
| mime="text/csv", |
| key=f"csv_download_{thread_id}" |
| ) |
|
|
| |
| if file_ids: |
| for file_type, file_id in file_ids: |
| if file_type == "file": |
| try: |
| |
| file_info = client.files.retrieve(file_id) |
| filename = file_info.filename |
|
|
| |
| file_content = client.files.content(file_id) |
| file_bytes = file_content.read() |
|
|
| |
| st.download_button( |
| label=f"📥 Download {filename}", |
| data=file_bytes, |
| file_name=filename, |
| mime="text/csv" if filename.endswith('.csv') else "application/octet-stream" |
| ) |
| except Exception as e: |
| st.error(f"Error downloading file: {str(e)}") |
|
|
| if thread_id and not st.session_state.thread_id: |
| st.session_state.thread_id = thread_id |
|
|
| st.session_state.messages.append({"role": "assistant", "content": cleaned_response}) |
|
|
| st.markdown("---") |
| st.caption("Dataset: 28,257 job listings | Supports CSV, Semantic, and Hybrid methods.") |
|
|