🏦 AI Matching Assistant
Najdi svou další kariérní příležitost v ČSOB · Diplomová práce
""" app.py – Streamlit PoC: AI Matching Assistant pro ČSOB Diplomová práce – Filip Husein Perspektiva ZAMĚSTNANCE (3-polní formulář): 1. Životopis (PDF/DOCX/TXT) – volitelné 2. Současná pozice + důvod pro změnu 3. Cokoliv dodat (koníčky, preference, silné stránky…) – volitelné Systém: - Vyloučí pozice odpovídající AKTUÁLNÍ roli zaměstnance (multilinguálně: CS/SK/EN – díky fine-tuned JobBERT-v3) - Doporučí top 5 pozic v ČSOB - Ollama vygeneruje přátelskou HR odpověď Spuštění: streamlit run app.py Prerekvizity: - Fine-tuned model v models/jobbert-v3-czsk-final/ - FAISS index v index/ - Ollama běží (ollama serve) s modelem llama3.2 """ import json import os import re import sys import time import unicodedata import numpy as np import streamlit as st # ============================================================================= # Konfigurace – musí být DEFINOVÁNA před prvním použitím BASE_DIR # ============================================================================= BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # Přidej aktuální adresář do PYTHONPATH sys.path.insert(0, BASE_DIR) # Model – fallback chain: HF snapshot (stažený v build_index_startup.py) → base MODEL_PATHS = [ os.path.join(BASE_DIR, "model"), # HF snapshot os.path.join(BASE_DIR, "models", "jobbert-v3-czsk-hn-final"), # lokální (volitelné) os.path.join(BASE_DIR, "models", "jobbert-v3-czsk-final"), # fallback "TechWolf/JobBERT-v3", # base ] INDEX_DIR = os.path.join(BASE_DIR, "index") FAISS_PATH = os.path.join(INDEX_DIR, "positions.faiss") META_PATH = os.path.join(INDEX_DIR, "positions_metadata.json") # Kolik kandidátů vytáhnout z FAISS (velká rezerva po filtru) TOP_K_SEARCH = 40 # Kolik finálně zobrazit uživateli TOP_K_SHOW = 5 # ── Exclusion filter ── # 0.88 = jen opravdu podobné role (varianty "Junior/Senior X"), ne příbuzné obory. EXCLUSION_SIM_THRESHOLD = 0.88 EXCLUSION_MAX_COUNT = 30 # hard cap – nikdy nevyhodíme víc pozic # ── Rescale raw cosine similarity pro user-friendly zobrazení ── # Fine-tuned model má gap cca pos=0.83, neg=0.08. # Relevantní "sousední" role padnou do range 0.25–0.70. # Mapujeme: 0.15 → 50%, 0.85 → 99% (lineárně), clamp na [20%, 99%]. DISPLAY_MIN_RAW = 0.15 DISPLAY_MAX_RAW = 0.85 DISPLAY_MIN_PCT = 50.0 DISPLAY_MAX_PCT = 99.0 def display_score(raw: float) -> float: """Přemapuj raw cosine similarity na user-facing procento (0–100).""" if raw is None: return 0.0 t = (raw - DISPLAY_MIN_RAW) / (DISPLAY_MAX_RAW - DISPLAY_MIN_RAW) t = max(0.0, min(1.0, t)) pct = DISPLAY_MIN_PCT + t * (DISPLAY_MAX_PCT - DISPLAY_MIN_PCT) return max(20.0, min(99.0, pct)) # ============================================================================= # Auto-build index + model při prvním startu (HF Spaces) # ============================================================================= if not os.path.exists(FAISS_PATH): import subprocess subprocess.run(["python", "build_index_startup.py"], check=True) from cv_parser import extract_cv_text, summarize_cv from rag_engine import ( synthesize_profile, generate_response, check_llm_available as check_ollama_available, # alias kvůli rest kódu llm_generate as ollama_generate, _fallback_response, ) # ============================================================================= # Lazy loading (cache pro Streamlit) # ============================================================================= @st.cache_resource(show_spinner="Načítám model JobBERT-v3...") def load_model(): """Načti SentenceTransformer model (cached).""" from sentence_transformers import SentenceTransformer for path in MODEL_PATHS: if os.path.isdir(path) or not path.startswith("/"): try: model = SentenceTransformer(path) return model, path except Exception as e: st.warning(f"Model {path} nelze načíst: {e}") continue st.error("Žádný model nebyl nalezen! Spusť nejdříve trénink (12_train_jobbert.py).") st.stop() @st.cache_resource(show_spinner="Načítám FAISS index...") def load_index(): """Načti FAISS index a metadata (cached).""" import faiss if not os.path.exists(FAISS_PATH): st.error(f"FAISS index nenalezen: {FAISS_PATH}\nSpusť: python 14_build_index.py") st.stop() index = faiss.read_index(FAISS_PATH) with open(META_PATH, "r", encoding="utf-8") as f: metadata = json.load(f) return index, metadata # ============================================================================= # Text normalizace (case + diakritika insensitive) # ============================================================================= def _normalize(s: str) -> str: """Lower + NFKD + odstraň diakritiku + squeeze whitespace.""" if not s: return "" s = s.lower() s = unicodedata.normalize("NFKD", s) s = "".join(c for c in s if not unicodedata.combining(c)) s = re.sub(r"\s+", " ", s).strip() return s def _title_contains_any(title: str, phrases: list[str]) -> bool: """Vrátí True, pokud titul obsahuje kteroukoli z frází (normalizovaně).""" t = _normalize(title) for p in phrases: p_norm = _normalize(p) if p_norm and p_norm in t: return True return False # ============================================================================= # Multilinguální varianty role (LLM translate) # ============================================================================= ROLE_TRANSLATE_PROMPT = """Přelož následující název pracovní pozice do češtiny, slovenštiny a angličtiny. Vrať POUZE validní JSON ve formátu {{"cs": "...", "sk": "...", "en": "..."}}. Žádný komentář, žádný markdown, pouze JSON. Pozice: {role} JSON:""" def get_role_variants(role_text: str, use_ollama: bool = True) -> list[str]: """ Vrátí seznam variant role ve 3 jazycích (CS/SK/EN) + původní text. Fallback: pouze původní text, pokud Ollama nedostupná. """ variants = {role_text.strip()} if not use_ollama or not check_ollama_available(): return [v for v in variants if v] try: prompt = ROLE_TRANSLATE_PROMPT.format(role=role_text) raw = ollama_generate(prompt, temperature=0.0, max_tokens=200) # Zkus najít JSON v odpovědi match = re.search(r"\{[^}]*\}", raw, re.DOTALL) if match: data = json.loads(match.group(0)) for key in ("cs", "sk", "en"): val = data.get(key) if isinstance(val, str) and val.strip(): variants.add(val.strip()) except Exception: pass # při chybě pokračujeme jen s původním textem return [v for v in variants if v] # ============================================================================= # Exclusion filter (multilinguální, embedding-based cluster) # ============================================================================= def compute_exclusion_ids( current_role_text: str, model, index, metadata: list[dict], role_variants: list[str] | None = None, similarity_threshold: float = EXCLUSION_SIM_THRESHOLD, max_exclusions: int = EXCLUSION_MAX_COUNT, ) -> tuple[set[int], list[dict]]: """ Vypočti ID pozic, které budou vyloučeny z doporučení. Strategie (multilinguální): 1. Zakóduj current_role (a případné přeložené varianty) fine-tuned modelem. Díky multilinguálnímu alignmentu to funguje napříč CS/SK/EN. 2. Najdi nejbližší pozice (≥ similarity_threshold) = "same role cluster". 3. Doplň substring match na titul (normalizovaně, všechny varianty). Returns: (set indexů k vyloučení, list dictů s debug informacemi) """ if not current_role_text or not current_role_text.strip(): return set(), [] # Texty k zakódování: hlavní + všechny překlady texts = [current_role_text] if role_variants: for v in role_variants: if v and v.lower() != current_role_text.lower(): texts.append(v) # Embed všechny varianty, použij maximum cosine similarity embeddings = model.encode( texts, normalize_embeddings=True, show_progress_bar=False, ) embeddings = np.array(embeddings, dtype=np.float32) k = min(max_exclusions, index.ntotal) # Pro každou variantu vytáhni top-k, sjednoť je exclusion_ids: set[int] = set() debug_rows: list[dict] = [] for emb in embeddings: scores, indices = index.search(emb.reshape(1, -1), k) for idx, score in zip(indices[0], scores[0]): if idx < 0: continue if score >= similarity_threshold: idx_int = int(idx) if idx_int not in exclusion_ids: exclusion_ids.add(idx_int) debug_rows.append({ "index_id": idx_int, "title": metadata[idx_int].get("title", ""), "score": float(score), "reason": "semantic", }) # Substring guard: projdi VŠECHNA metadata a zachyť i ta, co embedding # nechytil (jistota pro přesná shoda titulu) all_variants = list(set(texts)) for i, meta in enumerate(metadata): if i in exclusion_ids: continue if _title_contains_any(meta.get("title", ""), all_variants): exclusion_ids.add(i) debug_rows.append({ "index_id": i, "title": meta.get("title", ""), "score": None, "reason": "substring", }) return exclusion_ids, debug_rows # ============================================================================= # Matching logika # ============================================================================= def search_positions( query_text: str, model, index, metadata, top_k: int = TOP_K_SEARCH, exclude_ids: set[int] | None = None, ): """Zakóduj dotaz a najdi nejbližší pozice ve FAISS, s filtrací exclude_ids.""" embedding = model.encode( [query_text], normalize_embeddings=True, show_progress_bar=False ) embedding = np.array(embedding, dtype=np.float32) # Vytáhni rezervu (2× top_k + velikost exclude setu) extra = len(exclude_ids) if exclude_ids else 0 k = min(top_k + extra, index.ntotal) scores, indices = index.search(embedding, k) results = [] rank_counter = 1 for idx, score in zip(indices[0], scores[0]): if idx < 0: continue idx_int = int(idx) if exclude_ids and idx_int in exclude_ids: continue meta = metadata[idx_int].copy() meta["rank"] = rank_counter meta["score"] = float(score) meta["index_id"] = idx_int results.append(meta) rank_counter += 1 if len(results) >= top_k: break return results # ============================================================================= # Build vyhledávací text # ============================================================================= def build_search_query( current_role: str, reason: str, extras: str, cv_text: str, ) -> str: """ Sestaví text pro embedding / profile synthesis. DŮLEŽITÉ: current_role se sem NEZAPOJUJE přímo (jinak by model vytahoval podobné pozice zpět). Použijeme jen: důvod změny + extras + CV. """ parts = [] if reason and reason.strip(): parts.append(f"Hledám novou pozici, protože: {reason.strip()}") if extras and extras.strip(): parts.append(f"Další informace o mně: {extras.strip()}") if cv_text and cv_text.strip(): parts.append(f"Z životopisu: {cv_text.strip()[:1500]}") # Pokud není nic, aspoň roli jako fallback (aby se něco našlo) if not parts and current_role: parts.append(current_role) return "\n\n".join(parts) # ============================================================================= # Streamlit UI # ============================================================================= def main(): # Page config st.set_page_config( page_title="ČSOB – AI Matching Assistant", page_icon="🏦", layout="wide", initial_sidebar_state="collapsed", ) # Custom CSS st.markdown(""" """, unsafe_allow_html=True) # Header st.markdown("""
Najdi svou další kariérní příležitost v ČSOB · Diplomová práce