sample_job_listings_LLM / src /streamlit_app.py
bjj103's picture
Update src/streamlit_app.py
a0fb9b2 verified
Raw
History Blame Contribute Delete
25.4 kB
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."
)
@st.cache_data(show_spinner=False, ttl=120)
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.
"""
)