Spaces:
Runtime error
Runtime error
| import os, re, json | |
| from typing import List, Dict, Any, Optional, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| import streamlit as st | |
| from dotenv import load_dotenv, find_dotenv | |
| from openai import OpenAI | |
| import snowflake.connector as sf | |
| import re | |
| # ---------- Setup ---------- | |
| DOTENV_PATH = find_dotenv(usecwd=True) or os.path.join(os.getcwd(), ".env") | |
| load_dotenv(DOTENV_PATH, override=True) | |
| st.set_page_config(page_title="Job Listings (Conversational RAG)", page_icon="💼", layout="wide") | |
| st.title("💼 Adobe's Job Listings Conversational Search") | |
| # Env / config | |
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") | |
| SF_ACCOUNT = os.getenv("SNOWFLAKE_ACCOUNT") | |
| SF_USER = os.getenv("SNOWFLAKE_USER") | |
| SF_WAREHOUSE= os.getenv("SNOWFLAKE_WAREHOUSE") | |
| SF_DATABASE = os.getenv("SNOWFLAKE_DATABASE", "JOB_LISTINGS_100") | |
| SF_SCHEMA = os.getenv("SNOWFLAKE_SCHEMA", "PUBLIC") | |
| SF_ROLE = os.getenv("SNOWFLAKE_ROLE") | |
| SF_PAT = os.getenv("SNOWFLAKE_PAT") or os.getenv("SNOWFLAKE_PROGRAMMATIC_ACCESS_TOKEN") | |
| TABLE_NAME = os.getenv("JOB_TABLE", f"{SF_DATABASE}.{SF_SCHEMA}.SAMPLE_JOB_LISTINGS") | |
| TITLE_COL = "TITLE" | |
| DESC_COL = "DESCRIPTION" | |
| ID_COL = os.getenv("ID_COL", "ID") # if you don't have an ID, we’ll hash later | |
| # --- Brand/Vendor knowledge used by the LLM and SQL filters | |
| VENDOR_KB = { | |
| # Adobe (partial but high coverage) | |
| "photoshop":"Adobe","lightroom":"Adobe","illustrator":"Adobe","indesign":"Adobe","premiere pro":"Adobe", | |
| "after effects":"Adobe","audition":"Adobe","acrobat":"Adobe","xd":"Adobe","animate":"Adobe","bridge":"Adobe", | |
| "fresco":"Adobe","dimension":"Adobe","substance 3d":"Adobe","firefly":"Adobe","character animator":"Adobe", | |
| "media encoder":"Adobe","express":"Adobe", | |
| # Non-Adobe design / UI | |
| "figma":"Figma","sketch":"Sketch","canva":"Canva","coreldraw":"Corel","affinity designer":"Serif", | |
| "affinity photo":"Serif","affinity publisher":"Serif","quarkxpress":"Quark","inkscape":"Inkscape","krita":"Krita", | |
| # Non-Adobe photo/video/audio/3D | |
| "davinci resolve":"Blackmagic","final cut pro":"Apple","imovie":"Apple","logic pro":"Apple","garageband":"Apple", | |
| "media composer":"Avid","vegas pro":"MAGIX","capcut":"ByteDance", | |
| "blender":"Blender","maya":"Autodesk","3ds max":"Autodesk","autocad":"Autodesk","cinema 4d":"Maxon", | |
| "procreate":"Savage","capture one":"Phase One","gimp":"GIMP","unreal engine":"Epic","unity":"Unity" | |
| } | |
| ADOBE_SET = {k for k,v in VENDOR_KB.items() if v == "Adobe"} | |
| NON_ADOBE_SET = {k for k,v in VENDOR_KB.items() if v != "Adobe"} | |
| import re | |
| def _tool_alt_with_boundaries(names: list[str]) -> str: | |
| """ | |
| Build a Snowflake-friendly regex that matches any of the given tool names as whole tokens, | |
| without using look-behind. We: | |
| - lowercase and escape each name, | |
| - allow spaces or hyphens between words, | |
| - wrap with (^|[^A-Za-z0-9]) … ([^A-Za-z0-9]|$) as boundaries. | |
| """ | |
| def _escape_tool(n: str) -> str: | |
| s = re.escape(n.lower()) # escape regex metacharacters | |
| s = s.replace(r"\ ", r"[\s\-]+") # allow spaces/hyphens between words | |
| return s | |
| alts = "|".join(_escape_tool(n) for n in names) | |
| # No lookarounds; just capture boundary chars in groups 1 and 2. | |
| return rf"(^|[^A-Za-z0-9])(?:{alts})([^A-Za-z0-9]|$)" | |
| def count_adobe(conn) -> int: | |
| rx_adb = _tool_alt_with_boundaries(sorted(ADOBE_SET)) | |
| sql = f""" | |
| SELECT COUNT(*) | |
| FROM {TABLE_NAME} | |
| WHERE REGEXP_INSTR(LOWER({TITLE_COL}), %s) > 0 | |
| OR REGEXP_INSTR(LOWER({DESC_COL}), %s) > 0 | |
| """ | |
| with conn.cursor() as cur: | |
| cur.execute(sql, (rx_adb, rx_adb)) | |
| return int(cur.fetchone()[0] or 0) | |
| def count_non_adobe(conn) -> int: | |
| rx_non = _tool_alt_with_boundaries(sorted(NON_ADOBE_SET)) | |
| rx_adb = _tool_alt_with_boundaries(sorted(ADOBE_SET)) | |
| sql = f""" | |
| SELECT COUNT(*) | |
| FROM {TABLE_NAME} | |
| WHERE ( | |
| REGEXP_INSTR(LOWER({TITLE_COL}), %s) > 0 OR | |
| REGEXP_INSTR(LOWER({DESC_COL}), %s) > 0 | |
| ) | |
| AND NOT ( | |
| REGEXP_INSTR(LOWER({TITLE_COL}), %s) > 0 OR | |
| REGEXP_INSTR(LOWER({DESC_COL}), %s) > 0 | |
| ) | |
| """ | |
| with conn.cursor() as cur: | |
| cur.execute(sql, (rx_non, rx_non, rx_adb, rx_adb)) | |
| return int(cur.fetchone()[0] or 0) | |
| def count_adobe(conn) -> int: | |
| rx_adb = _word_boundary_regex(sorted(ADOBE_SET)) | |
| sql = f""" | |
| SELECT COUNT(*) | |
| FROM {TABLE_NAME} | |
| WHERE REGEXP_INSTR(LOWER({TITLE_COL}), %s) > 0 | |
| OR REGEXP_INSTR(LOWER({DESC_COL}), %s) > 0 | |
| """ | |
| with conn.cursor() as cur: | |
| cur.execute(sql, (rx_adb, rx_adb)) | |
| return int(cur.fetchone()[0] or 0) | |
| def count_non_adobe(conn) -> int: | |
| rx_non = _word_boundary_regex(sorted(NON_ADOBE_SET)) | |
| rx_adb = _word_boundary_regex(sorted(ADOBE_SET)) | |
| sql = f""" | |
| SELECT COUNT(*) | |
| FROM {TABLE_NAME} | |
| WHERE ( | |
| REGEXP_INSTR(LOWER({TITLE_COL}), %s) > 0 OR | |
| REGEXP_INSTR(LOWER({DESC_COL}), %s) > 0 | |
| ) | |
| AND NOT ( | |
| REGEXP_INSTR(LOWER({TITLE_COL}), %s) > 0 OR | |
| REGEXP_INSTR(LOWER({DESC_COL}), %s) > 0 | |
| ) | |
| """ | |
| with conn.cursor() as cur: | |
| cur.execute(sql, (rx_non, rx_non, rx_adb, rx_adb)) | |
| return int(cur.fetchone()[0] or 0) | |
| # Embedding columns (native VECTOR types) | |
| EMB_COL_TITLE = "EMBEDDING_TITLE" | |
| EMB_COL_DESC = "EMBEDDING_DESCRIPTION" | |
| # Models | |
| EMBED_MODEL = "text-embedding-3-large" # 3072 dims | |
| CHAT_MODEL = "gpt-4o-mini" | |
| if not OPENAI_API_KEY: | |
| st.error("Missing OPENAI_API_KEY in environment/.env") | |
| st.stop() | |
| client = OpenAI(api_key=OPENAI_API_KEY) | |
| # ---------- Unified Persona ---------- | |
| ASSISTANT_PERSONA = """ | |
| You are a research assistant for job listings focused on Creative Professionals (“CPros”). | |
| Be concise, factual, and context-aware; use plain language. | |
| Creative Professionals (or CPros) are workers who complete tasks requiring imagination, originality, | |
| and artistic skills. They produce content that is visually innovative, expressive, or aesthetically engaging, such as: | |
| - Photo/image editing (e.g., cropping, filters, compositing) | |
| - Video/audio editing (e.g., trimming/combining clips, effects, music production, motion design) | |
| - Graphic design or digital drawing/painting (e.g., icons, logos, graphics, illustrations) | |
| - Web/app design (e.g., site/app design, flows, wireframes) | |
| - Layout design (e.g., flyers, posters, brochures, magazines) | |
| - 3D design or immersive experiences (e.g., modeling, animation, 3D aggregation) | |
| Examples: Graphic Designers, UX/UI Designers, Game Designers, Video Editors, Photographers, Animators, | |
| Special Effects Artists. By contrast, Chief Executive Officers, Accountants, Truck Drivers, and house Painters | |
| are not digital Creative Professionals. | |
| Sub-groups and synonyms: | |
| - “Photo Pros” → occupations heavy in photography and photo-editing (Photographers, Retouchers). | |
| - “Video Pros” → occupations mainly using video/audio editing software (Video Editors, Sound Designers). | |
| - “Design Pros” → occupations using design software (Illustrator, Canva, Figma, AutoCAD, UX/UI). | |
| Normalization: | |
| - Treat “Creative Professional(s)” and “CPro(s)” as synonyms. | |
| - Treat “Photo Pros”, “Video Pros”, “Design Pros” and common variants as the above categories. | |
| - Prefer TITLE when the user asks about roles; prefer DESCRIPTION when the user asks about skills/tools/tasks. | |
| """ | |
| # --- Brand guide injected into the intent system prompt | |
| BRAND_GUIDE = ( | |
| "Known apps and vendors (lowercase keys): " | |
| + json.dumps(VENDOR_KB, ensure_ascii=False) | |
| + "\nAdobe apps: " | |
| + json.dumps(sorted(ADOBE_SET), ensure_ascii=False) | |
| + "\nNon-Adobe apps: " | |
| + json.dumps(sorted(NON_ADOBE_SET), ensure_ascii=False) | |
| ) | |
| INTENT_SYS = f""" | |
| {ASSISTANT_PERSONA} | |
| Use this brand guide when classifying queries about 'Adobe' vs 'non-Adobe': | |
| {BRAND_GUIDE} | |
| Task: Analyze the user's question about job listings and return STRICT JSON: | |
| {{ | |
| "intent": "TITLE" | "DESCRIPTION" | "BOTH", | |
| "detail": "titles" | "detailed", | |
| "keywords": [string, ...], | |
| "followup": "NONE" | "FILTER" | "EXPAND" | "RELATED", | |
| "cpro": boolean, | |
| "photo_pro": boolean, | |
| "video_pro": boolean, | |
| "design_pro": boolean | |
| }} | |
| Rules: | |
| - Set boolean flags true if the query refers to those groups or synonyms. | |
| - If role-centric → "TITLE"; if skill/tool/task-centric → "DESCRIPTION". | |
| - Return ONLY JSON; no extra text. | |
| """ | |
| FOLLOWUP_SYS = f""" | |
| {ASSISTANT_PERSONA} | |
| Task: Given a user's query and top job listings (title + short snippet), | |
| return STRICT JSON: | |
| {{ | |
| "summary": string, # 2–4 lines, factual, grounded in results | |
| "suggestions": [string,string,string] | |
| }} | |
| Style: concise, professional, grounded in evidence; return ONLY JSON. | |
| """ | |
| # ---------- Helpers ---------- | |
| def connect_snowflake() -> sf.SnowflakeConnection: | |
| return sf.connect( | |
| account=SF_ACCOUNT, | |
| user=SF_USER, | |
| password=SF_PAT, | |
| warehouse=SF_WAREHOUSE, | |
| database=SF_DATABASE, | |
| schema=SF_SCHEMA, | |
| role=SF_ROLE, | |
| client_session_keep_alive=True, | |
| ) | |
| def embed_texts(texts: List[str]) -> np.ndarray: | |
| if not texts: | |
| return np.zeros((0, 3072), dtype=np.float32) | |
| resp = client.embeddings.create(model=EMBED_MODEL, input=texts) | |
| vecs = [np.array(d.embedding, dtype=np.float32) for d in resp.data] | |
| return np.vstack(vecs) | |
| def analyze_query(history: List[Dict[str,str]], user_query: str) -> Dict[str, Any]: | |
| msgs = [{"role":"system","content":INTENT_SYS}] + history + [{"role":"user","content":user_query}] | |
| res = client.chat.completions.create( | |
| model=CHAT_MODEL, messages=msgs, temperature=0.2, response_format={"type":"json_object"} | |
| ) | |
| try: | |
| return json.loads(res.choices[0].message.content) | |
| except Exception: | |
| return {"intent":"BOTH","detail":"detailed","keywords":[],"followup":"NONE"} | |
| def summarize_and_suggest(user_query: str, results: pd.DataFrame) -> Dict[str, Any]: | |
| sample = results[[TITLE_COL, DESC_COL]].head(6).to_dict(orient="records") if not results.empty else [] | |
| msgs = [ | |
| {"role":"system","content":FOLLOWUP_SYS}, | |
| {"role":"user","content":json.dumps({"query": user_query, "results": sample})}, | |
| ] | |
| res = client.chat.completions.create( | |
| model=CHAT_MODEL, messages=msgs, temperature=0.3, response_format={"type":"json_object"} | |
| ) | |
| try: | |
| return json.loads(res.choices[0].message.content) | |
| except Exception: | |
| return {"summary": "", "suggestions": []} | |
| def is_count_query(q: str) -> bool: | |
| COUNT_PATTERNS = (r"\bhow many\b", r"\bcount\b", r"\bnumber of\b") | |
| ql = q.lower() | |
| return any(re.search(p, ql) for p in COUNT_PATTERNS) | |
| def primary_keyword(q: str, llm_keywords: List[str]) -> Optional[str]: | |
| return (llm_keywords[0] if llm_keywords else None) | |
| # --- Heuristic CPro count (method #2) --- | |
| def run_count_cpro_heuristic(conn) -> int: | |
| inc_terms = [ | |
| "photoshop","lightroom","illustrator","indesign","figma","sketch","canva","autocad", | |
| "after effects","premiere","davinci","resolve","blender","maya","3d","animation", | |
| "ux","ui","graphic","designer","retoucher","photograph","video","motion","editor", | |
| "visual","art","layout","brand","creative","content creator" | |
| ] | |
| exc_terms = [ | |
| "accountant","driver","warehouse","receptionist","cashier","cook","barista","janitor","ceo","chief executive" | |
| ] | |
| inc = " OR ".join([f"TITLE ILIKE '%{w}%'" for w in inc_terms] + [f"DESCRIPTION ILIKE '%{w}%'" for w in inc_terms]) | |
| exc = " OR ".join([f"TITLE ILIKE '%{w}%'" for w in exc_terms] + [f"DESCRIPTION ILIKE '%{w}%'" for w in exc_terms]) | |
| sql = f"SELECT COUNT(*) FROM {TABLE_NAME} WHERE ({inc}) AND NOT ({exc})" | |
| with conn.cursor() as cur: | |
| cur.execute(sql) | |
| row = cur.fetchone() | |
| return int(row[0] or 0) | |
| def run_count_titles(conn, keyword: Optional[str], use_desc: bool) -> int: | |
| txt_col = DESC_COL if use_desc else TITLE_COL | |
| with conn.cursor() as cur: | |
| if keyword: | |
| safe = f"%{keyword}%" | |
| cur.execute(f"SELECT COUNT(DISTINCT {TITLE_COL}) FROM {TABLE_NAME} WHERE {txt_col} ILIKE %s", (safe,)) | |
| else: | |
| cur.execute(f"SELECT COUNT(DISTINCT {TITLE_COL}) FROM {TABLE_NAME}") | |
| row = cur.fetchone() | |
| return int(row[0] or 0) | |
| # --- Vector search (Snowflake native → fallback) --- | |
| def _sql_try_vector_distance(cur, qvec: np.ndarray, top_k: int, use_desc: bool, ilike_kw: Optional[str]) -> Optional[pd.DataFrame]: | |
| emb_col = EMB_COL_DESC if use_desc else EMB_COL_TITLE | |
| qlist = ",".join(str(x) for x in qvec.astype(float).tolist()) | |
| where_parts = [] | |
| if ilike_kw: | |
| safe = ilike_kw.replace("'", "''") | |
| where_parts.append(f"({TITLE_COL} ILIKE '%{safe}%' OR {DESC_COL} ILIKE '%{safe}%')") | |
| where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" | |
| sql = f""" | |
| SELECT {ID_COL}, {TITLE_COL}, {DESC_COL}, | |
| VECTOR_DISTANCE({emb_col}, TO_VECTOR(ARRAY_CONSTRUCT({qlist})), 'COSINE') AS score | |
| FROM {TABLE_NAME} | |
| {where_sql} | |
| ORDER BY score ASC | |
| LIMIT {top_k} | |
| """ | |
| try: | |
| cur.execute(sql); rows = cur.fetchall() | |
| cols = [c[0] for c in cur.description] | |
| return pd.DataFrame(rows, columns=cols) | |
| except Exception: | |
| return None | |
| def _sql_try_cosine_similarity(cur, qvec: np.ndarray, top_k: int, use_desc: bool, ilike_kw: Optional[str]) -> Optional[pd.DataFrame]: | |
| emb_col = EMB_COL_DESC if use_desc else EMB_COL_TITLE | |
| qlist = ",".join(str(x) for x in qvec.astype(float).tolist()) | |
| where_parts = [] | |
| if ilike_kw: | |
| safe = ilike_kw.replace("'", "''") | |
| where_parts.append(f"({TITLE_COL} ILIKE '%{safe}%' OR {DESC_COL} ILIKE '%{safe}%')") | |
| where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" | |
| sql = f""" | |
| SELECT {ID_COL}, {TITLE_COL}, {DESC_COL}, | |
| VECTOR_COSINE_SIMILARITY({emb_col}, TO_VECTOR(ARRAY_CONSTRUCT({qlist}))) AS score | |
| FROM {TABLE_NAME} | |
| {where_sql} | |
| ORDER BY score DESC | |
| LIMIT {top_k} | |
| """ | |
| try: | |
| cur.execute(sql); rows = cur.fetchall() | |
| cols = [c[0] for c in cur.description] | |
| return pd.DataFrame(rows, columns=cols) | |
| except Exception: | |
| return None | |
| def _py_fallback_cosine(cur, qvec: np.ndarray, top_k: int, use_desc: bool, ilike_kw: Optional[str]) -> pd.DataFrame: | |
| emb_col = EMB_COL_DESC if use_desc else EMB_COL_TITLE | |
| where_parts = [] | |
| if ilike_kw: | |
| safe = ilike_kw.replace("'", "''") | |
| where_parts.append(f"({TITLE_COL} ILIKE '%{safe}%' OR {DESC_COL} ILIKE '%{safe}%')") | |
| where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" | |
| sql = f""" | |
| SELECT {ID_COL}, {TITLE_COL}, {DESC_COL}, {emb_col} | |
| FROM {TABLE_NAME} | |
| {where_sql} | |
| LIMIT {max(top_k*10, 500)} | |
| """ | |
| cur.execute(sql); rows = cur.fetchall() | |
| cols = [c[0] for c in cur.description] | |
| df = pd.DataFrame(rows, columns=cols) | |
| if df.empty: | |
| return df.assign(score=np.nan) | |
| # ensure numpy arrays | |
| df[emb_col] = df[emb_col].apply(lambda v: np.array(list(v), dtype=np.float32)) | |
| mat = np.vstack(df[emb_col].values) | |
| qn = qvec.reshape(1, -1); qn = qn / (np.linalg.norm(qn, axis=1, keepdims=True) + 1e-9) | |
| mn = mat / (np.linalg.norm(mat, axis=1, keepdims=True) + 1e-9) | |
| sims = (mn @ qn.T).ravel() | |
| df = df.drop(columns=[emb_col]); df["score"] = sims | |
| return df.sort_values("score", ascending=False).head(top_k).reset_index(drop=True) | |
| def vector_search(conn, query_text: str, top_k: int, use_desc: bool, ilike_kw: Optional[str]) -> Tuple[pd.DataFrame, str]: | |
| qvec = embed_texts([query_text])[0] | |
| with conn.cursor() as cur: | |
| df = _sql_try_vector_distance(cur, qvec, top_k, use_desc, ilike_kw) | |
| if df is not None and not df.empty: | |
| return df, "VECTOR_DISTANCE(COSINE)" | |
| df = _sql_try_cosine_similarity(cur, qvec, top_k, use_desc, ilike_kw) | |
| if df is not None and not df.empty: | |
| return df, "VECTOR_COSINE_SIMILARITY" | |
| df = _py_fallback_cosine(cur, qvec, top_k, use_desc, ilike_kw) | |
| return df, "python_cosine_fallback" | |
| # ---------- UI (sidebar/options) ---------- | |
| with st.sidebar: | |
| st.subheader("Options") | |
| focus = st.selectbox( | |
| "Focus", | |
| ["AUTO","TITLE","DESCRIPTION"], | |
| index=0, | |
| help="AUTO uses the intent planner; otherwise force TITLE or DESCRIPTION." | |
| ) | |
| ilike_kw = st.text_input( | |
| "Optional keyword filter", | |
| help="Restrict results to rows whose TITLE or DESCRIPTION contains this word/phrase." | |
| ) | |
| def get_max_k(focus_opt: str, ilike_opt: str) -> int: | |
| """ | |
| Count rows that have embeddings available for the active focus, | |
| optionally applying an ILIKE filter. This anchors the Top-K slider. | |
| """ | |
| emb_col = { | |
| "TITLE": "EMBEDDING_TITLE", | |
| "DESCRIPTION": "EMBEDDING_DESCRIPTION", | |
| }.get(focus_opt, None) # AUTO -> None (count rows that have either) | |
| where = [] | |
| params = [] | |
| if ilike_opt: | |
| where.append("(TITLE ILIKE %s OR DESCRIPTION ILIKE %s)") | |
| like = f"%{ilike_opt}%" | |
| params.extend([like, like]) | |
| if emb_col: | |
| where.append(f"{emb_col} IS NOT NULL") | |
| else: | |
| # AUTO: allow either column to be present | |
| where.append("(EMBEDDING_TITLE IS NOT NULL OR EMBEDDING_DESCRIPTION IS NOT NULL)") | |
| where_sql = "WHERE " + " AND ".join(where) if where else "" | |
| sql = f"SELECT COUNT(*) FROM {TABLE_NAME} {where_sql}" | |
| try: | |
| conn = connect_snowflake() | |
| with conn.cursor() as cur: | |
| cur.execute(sql, tuple(params) if params else None) | |
| n = cur.fetchone()[0] or 0 | |
| finally: | |
| try: conn.close() | |
| except Exception: pass | |
| return max(int(n), 1) # avoid 0 for slider bounds | |
| max_k = get_max_k(focus, ilike_kw) | |
| # Make slider max + default dynamic | |
| step = 10 if max_k > 100 else 1 | |
| top_k = st.slider( | |
| "Top-K", | |
| min_value=1, | |
| max_value=max_k, | |
| value=max_k, # default = max (your request) | |
| step=step, | |
| help="How many similar job postings to fetch per query (limited to rows with embeddings and optional filter)." | |
| ) | |
| show_table = st.checkbox("Show results table", value=False) | |
| show_debug = st.checkbox("Show debug info (engine/focus)", value=False) | |
| if st.button("🧹 Clear conversation / context"): | |
| st.session_state.history = [] | |
| st.session_state.last_results = None | |
| st.session_state.last_query = None | |
| st.session_state.last_intent = {} | |
| st.success("Conversation context cleared.") | |
| # ---------- Conversation state ---------- | |
| if "history" not in st.session_state: st.session_state.history = [] | |
| if "last_results" not in st.session_state: st.session_state.last_results = None | |
| if "last_query" not in st.session_state: st.session_state.last_query = None | |
| if "last_intent" not in st.session_state: st.session_state.last_intent = {} | |
| # Render conversation so far | |
| for m in st.session_state.history: | |
| st.chat_message(m["role"]).markdown(m["content"]) | |
| prompt = st.chat_input("Ask about job titles, tools, or tasks…") | |
| # ---------- Single render path ---------- | |
| if prompt: | |
| st.session_state.history.append({"role":"user","content":prompt}) | |
| # Intent | |
| strategy = analyze_query(st.session_state.history[:-1], prompt) | |
| st.session_state.last_intent = strategy | |
| llm_intent = (strategy.get("intent") or "BOTH").upper() | |
| use_desc = { | |
| "TITLE": False, | |
| "DESCRIPTION": True, | |
| "BOTH": (focus == "DESCRIPTION"), | |
| }.get(llm_intent, (focus == "DESCRIPTION")) | |
| if focus == "TITLE": use_desc = False | |
| if focus == "DESCRIPTION": use_desc = True | |
| # Derived flags/keywords (compute BEFORE branching) | |
| kw = primary_keyword(prompt, strategy.get("keywords", []) or []) | |
| cpro_flag = bool(strategy.get("cpro")) | |
| photo_flag = bool(strategy.get("photo_pro")) | |
| video_flag = bool(strategy.get("video_pro")) | |
| design_flag = bool(strategy.get("design_pro")) | |
| do_count = is_count_query(prompt) | |
| ql = prompt.lower() | |
| is_non_adobe_q = bool(re.search(r"\bnon[-\s]?adobe\b", ql)) | |
| is_adobe_q = ("adobe" in ql) and not is_non_adobe_q | |
| results: Optional[pd.DataFrame] = None | |
| mode_used = "" | |
| try: | |
| conn = connect_snowflake() | |
| if do_count: | |
| if is_non_adobe_q: | |
| n = count_non_adobe(conn) | |
| results = pd.DataFrame({"Metric":["Listings with non-Adobe apps"], "Value":[n]}) | |
| mode_used = "COUNT non-Adobe (regex)" | |
| elif is_adobe_q: | |
| n = count_adobe(conn) | |
| results = pd.DataFrame({"Metric":["Listings with Adobe apps"], "Value":[n]}) | |
| mode_used = "COUNT Adobe (regex)" | |
| elif cpro_flag: | |
| n = run_count_cpro_heuristic(conn) | |
| results = pd.DataFrame({"Metric":["CPro jobs (heuristic)"], "Value":[n]}) | |
| mode_used = "COUNT heuristic (CPro)" | |
| else: | |
| n = run_count_titles(conn, keyword=kw or ilike_kw, use_desc=use_desc) | |
| results = pd.DataFrame({"Metric":["Distinct job titles"], "Value":[n]}) | |
| mode_used = f"COUNT on {'DESCRIPTION' if use_desc else 'TITLE'} (ILIKE)" | |
| else: | |
| results, mode_used = vector_search(conn, prompt, top_k=top_k, use_desc=use_desc, ilike_kw=ilike_kw or kw) | |
| except Exception as e: | |
| st.error(f"Snowflake error: {e}") | |
| results = pd.DataFrame() | |
| mode_used = "error" | |
| finally: | |
| try: conn.close() | |
| except Exception: pass | |
| # Safety: ensure DataFrame | |
| if results is None or not isinstance(results, pd.DataFrame): | |
| results = st.session_state.get("last_results") | |
| if results is None or not isinstance(results, pd.DataFrame): | |
| results = pd.DataFrame() | |
| # Persist for follow-ups | |
| st.session_state.last_query = prompt | |
| st.session_state.last_results = results | |
| # ---- Render (once) ---- | |
| if do_count: | |
| n = int(results.loc[0, "Value"]) if not results.empty else 0 | |
| # Make the count message contextual | |
| if is_non_adobe_q: | |
| msg = f"I found **{n}** listings that include non-Adobe apps." | |
| elif is_adobe_q: | |
| msg = f"I found **{n}** listings that include Adobe apps." | |
| elif cpro_flag: | |
| msg = f"I found **{n}** listings likely targeting Creative Professionals." | |
| else: | |
| msg = f"I found **{n}** distinct job titles matching your request." | |
| st.chat_message("assistant").markdown(msg) | |
| st.session_state.history.append({"role":"assistant","content":msg}) | |
| if show_table and not results.empty: | |
| st.dataframe(results, use_container_width=True, hide_index=True) | |
| if show_debug: | |
| st.caption(f"Debug: mode={mode_used}, focus={'DESCRIPTION' if use_desc else 'TITLE'}") | |
| st.stop() | |
| else: | |
| if results.empty: | |
| msg = "I couldn’t find any relevant job titles for that—try rephrasing or adding a keyword." | |
| st.chat_message("assistant").markdown(msg) | |
| st.session_state.history.append({"role":"assistant","content":msg}) | |
| st.stop() | |
| example_titles = ( | |
| results[TITLE_COL].dropna().astype(str).head(8).tolist() | |
| if TITLE_COL in results.columns else [] | |
| ) | |
| try: | |
| sug = summarize_and_suggest(prompt, results) | |
| summary = (sug.get("summary") or "").strip() or "Here are example job titles:" | |
| except Exception: | |
| summary = "Here are example job titles:" | |
| bullet_lines = "\n".join([f"- {t}" for t in example_titles]) | |
| msg = f"{summary}\n\n**Examples:**\n{bullet_lines}" if example_titles else summary | |
| st.chat_message("assistant").markdown(msg) | |
| st.session_state.history.append({"role":"assistant","content":msg}) | |
| if show_table: | |
| show_cols = [c for c in [TITLE_COL, DESC_COL, "score"] if c in results.columns] | |
| st.dataframe(results[show_cols], use_container_width=True, hide_index=True) | |
| if show_debug: | |
| st.caption(f"Debug: mode={mode_used}, focus={'DESCRIPTION' if use_desc else 'TITLE'}") | |
| st.stop() | |
| with st.expander("ℹ️ Setup Notes"): | |
| st.markdown( | |
| """ | |
| **Environment variables (.env)** | |
| - `OPENAI_API_KEY` | |
| - `SNOWFLAKE_ACCOUNT`, `SNOWFLAKE_USER`, `SNOWFLAKE_WAREHOUSE`, `SNOWFLAKE_DATABASE`, `SNOWFLAKE_SCHEMA`, `SNOWFLAKE_ROLE`, `SNOWFLAKE_PAT` | |
| **Table & columns** | |
| - Table: `JOB_LISTINGS_100.PUBLIC.SAMPLE_JOB_LISTINGS` | |
| - Text: `TITLE`, `DESCRIPTION` | |
| - Vectors: `EMBEDDING_TITLE`, `EMBEDDING_DESCRIPTION` (Snowflake VECTOR) | |
| **How it works** | |
| - Conversational: history kept in `st.session_state.history`. | |
| - Intent planner detects **count** queries; otherwise runs **semantic search**. | |
| - Uses native Snowflake vector similarity when available; falls back to Python cosine. | |
| """ | |
| ) | |