Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import re | |
| from typing import List, Tuple, Dict, Any | |
| import gradio as gr | |
| from groq import Groq | |
| from PyPDF2 import PdfReader | |
| from docx import Document | |
| from sentence_transformers import SentenceTransformer | |
| import numpy as np | |
| from transformers import AutoTokenizer, pipeline | |
| # ============================ | |
| # Config | |
| # ============================ | |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "").strip() | |
| if not GROQ_API_KEY: | |
| raise RuntimeError( | |
| "Set env var GROQ_API_KEY in PyCharm Run Configuration (Environment variables)." | |
| ) | |
| client = Groq(api_key=GROQ_API_KEY) | |
| ANALYZE_MODEL = "llama-3.1-8b-instant" | |
| LETTER_MODEL = "llama-3.3-70b-versatile" | |
| # Fixed params (no sliders) | |
| DEFAULT_TEMPERATURE = 0.3 | |
| DEFAULT_MAX_TOKENS_ANALYZE = 1200 | |
| DEFAULT_MAX_TOKENS_LETTER = 600 | |
| TITLE = "<h2>ATS Resume Analyzer (Groq)</h2>" | |
| RESUME_ANALYZER_INSTRUCTIONS = """ | |
| <div style="padding: 10px; border-radius: 6px; border: 1px solid #ddd; margin-bottom: 10px;"> | |
| <p><strong>Инструкция:</strong></p> | |
| <ul> | |
| <li>Вакансию можно вставить текстом или загрузить файлом (PDF/DOCX/TXT).</li> | |
| <li>Нажмите “Analyze Resume”.</li> | |
| </ul> | |
| </div> | |
| """ | |
| COVER_LETTER_INSTRUCTIONS = """ | |
| <div style="padding: 10px; border-radius: 6px; border: 1px solid #ddd; margin-bottom: 10px;"> | |
| <p><strong>Инструкция для письма:</strong></p> | |
| <ol> | |
| <li>Загрузите резюме и вакансию на первой вкладке</li> | |
| <li>Выберите стиль сопроводительного письма</li> | |
| <li>Нажмите "Generate Cover Letter"</li> | |
| </ol> | |
| <p style="margin-top: 10px; font-size: 14px; color: #666;"> | |
| <strong>Совет:</strong> Попробуйте разные стили для разных типов компаний: | |
| <br>• Формальный — для корпораций и банков | |
| <br>• Современный — для IT-компаний и стартапов | |
| <br>• Креативный — для дизайнеров, маркетологов | |
| </p> | |
| </div> | |
| """ | |
| QA_INSTRUCTIONS = """ | |
| <div style="padding: 10px; border-radius: 6px; border: 1px solid #ddd; margin-bottom: 10px;"> | |
| <p><strong>Инструкция для Q&A:</strong></p> | |
| <ul> | |
| <li>Загрузите резюме и/или вакансию на первой вкладке (или вставьте текст).</li> | |
| <li>На этой вкладке задайте вопрос по резюме/вакансии.</li> | |
| </ul> | |
| </div> | |
| """ | |
| # ============================ | |
| # Cover Letter Types | |
| # ============================ | |
| COVER_LETTER_TYPES = { | |
| "formal": { | |
| "name": "Формальный стиль", | |
| "description": "Классическое деловое письмо, строгое и профессиональное", | |
| "prompt_template": """Напиши формальное сопроводительное письмо на русском языке в деловом стиле. Требования: | |
| - Используй официально-деловой стиль с уважительным обращением | |
| - Структура: приветствие, представление, соответствие требованиям вакансии, заключение | |
| - Объём: 8-10 предложений | |
| - Избегай разговорных выражений, используй стандартные формулировки | |
| - Ссылайся на конкретные требования вакансии | |
| Вакансия: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx}""" | |
| }, | |
| "modern": { | |
| "name": "Современный стиль", | |
| "description": "Современный прямой стиль, популярный в IT и стартапах", | |
| "prompt_template": """Напиши современное сопроводительное письмо на русском языке. Требования: | |
| - Современный, прямой стиль без излишней формальности | |
| - Акцент на конкретных результатах и метриках | |
| - Структура: краткое введение, ключевые достижения, почему подхожу, призыв к действию | |
| - 6-8 предложений, без шаблонных фраз | |
| - Используй активные глаголы и конкретные примеры | |
| Вакансия: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx}""" | |
| }, | |
| "technical": { | |
| "name": "Технический фокус", | |
| "description": "С акцентом на технические навыки и конкретные технологии", | |
| "prompt_template": """Напиши технически ориентированное сопроводительное письмо на русском языке для IT-off специалиста. Требования: | |
| - Фокус на технических навыках и технологиях из вакансии | |
| - Конкретные примеры использования технологий из резюме | |
| - Упоминание методологий, инструментов, фреймворков | |
| - Структура: техническое соответствие, опыт работы с конкретным стеком, релевантные проекты | |
| - Объём: 7-9 предложений | |
| Вакансия: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx}""" | |
| }, | |
| "creative": { | |
| "name": "Креативный подход", | |
| "description": "Творческий стиль для дизайнеров, маркетологов, копирайтеров", | |
| "prompt_template": """Напиши креативное сопроводительное письмо на русском языке. Требования: | |
| - Креативный, нешаблонный подход, но сохраняя профессионализм | |
| - Можно использовать метафоры или нестандартные сравнения (если уместно) | |
| - Покажи творческий подход через структуру или формулировки | |
| - Подчеркни креативные достижения и проекты | |
| - Объём: 8-10 предложений, можно чуть больше | |
| Вакансия: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx}""" | |
| }, | |
| "minimal": { | |
| "name": "Минималистичный", | |
| "description": "Краткое, по существу, без лишних слов", | |
| "prompt_template": """Напиши минималистичное сопроводительное письмо на русском языке. Требования: | |
| - Максимально кратко, без вводных слов и шаблонных фраз | |
| - Только самое важное: соответствие требованиям, ключевой опыт | |
| - 4-6 предложений, только по существу | |
| - Прямой стиль, без эмоциональных окрасов | |
| Вакансия: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx}""" | |
| }, | |
| "impact": { | |
| "name": "Результато-ориентированный", | |
| "description": "С акцентом на конкретные результаты и достижения", | |
| "prompt_template": """Напиши сопроводительное письмо на русском с фокусом на результаты и достижения. Требования: | |
| - Каждый абзац начинай с конкретного результата или достижения | |
| - Используй метрики и цифры из резюме | |
| - Связывай свои достижения с потребностями вакансии | |
| - Структура: ключевой результат, как он достигался, как поможет компании | |
| - 6-8 предложений | |
| Вакансия: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx}""" | |
| } | |
| } | |
| # ============================ | |
| # Models (local transformers) | |
| # ============================ | |
| E5_MODEL_NAME = "intfloat/e5-small-v2" # embeddings | |
| ZSHOT_MODEL_NAME = "MoritzLaurer/multilingual-MiniLMv2-L6-mnli-xnli" # zero-shot classification | |
| QA_MODEL_MULTI = "timpal0l/mdeberta-v3-base-squad2" # multilingual SQuAD2 | |
| QA_MODEL_RU = "sad-bkt/rubert-finetuned-squad" # RuBERT fine-tuned on sberquad | |
| _qa_pipes: Dict[str, Any] = {} | |
| _qa_toks: Dict[str, AutoTokenizer] = {} | |
| _emb_model: SentenceTransformer | None = None | |
| _zshot_pipe = None | |
| _zshot_tok: AutoTokenizer | None = None | |
| ROLE_LABELS = [ | |
| "Android", | |
| "Backend", | |
| "Data", | |
| "DevOps", | |
| "QA", | |
| "Product", | |
| "Design", | |
| "Security", | |
| "Mobile", | |
| "Web", | |
| ] | |
| LEVEL_LABELS = ["Intern", "Junior", "Middle", "Senior", "Lead"] | |
| def get_emb_model() -> SentenceTransformer: | |
| global _emb_model | |
| if _emb_model is None: | |
| _emb_model = SentenceTransformer(E5_MODEL_NAME) | |
| return _emb_model | |
| def get_zshot(): | |
| """Lazy init so UI starts faster.""" | |
| global _zshot_pipe, _zshot_tok | |
| if _zshot_pipe is None: | |
| _zshot_pipe = pipeline("zero-shot-classification", model=ZSHOT_MODEL_NAME) | |
| _zshot_tok = AutoTokenizer.from_pretrained(ZSHOT_MODEL_NAME) | |
| return _zshot_pipe, _zshot_tok | |
| def get_qa(model_name: str): | |
| """Extractive QA pipeline (SQuAD2-style), cached per model.""" | |
| pipe = _qa_pipes.get(model_name) | |
| tok = _qa_toks.get(model_name) | |
| if pipe is None: | |
| pipe = pipeline("question-answering", model=model_name, tokenizer=model_name) | |
| tok = AutoTokenizer.from_pretrained(model_name) | |
| _qa_pipes[model_name] = pipe | |
| _qa_toks[model_name] = tok | |
| return pipe, tok | |
| def truncate_for_zshot(text: str, max_tokens: int = 450) -> str: | |
| _, tok = get_zshot() | |
| assert tok is not None | |
| enc = tok( | |
| text, | |
| truncation=True, | |
| max_length=max_tokens, | |
| return_tensors=None, | |
| add_special_tokens=False, | |
| ) | |
| return tok.decode(enc["input_ids"], skip_special_tokens=True) | |
| def zshot_top3(text: str, labels: List[str]) -> List[Tuple[str, float]]: | |
| zshot, _ = get_zshot() | |
| text = truncate_for_zshot(text, max_tokens=450) | |
| res = zshot( | |
| text, | |
| candidate_labels=labels, | |
| hypothesis_template="Этот текст относится к теме {}.", | |
| multi_label=False, | |
| ) | |
| return list(zip(res["labels"][:3], [float(x) for x in res["scores"][:3]])) | |
| # ============================ | |
| # Text utils | |
| # ============================ | |
| def normalize_text(s: str) -> str: | |
| s = s or "" | |
| s = s.replace("\x00", "") | |
| s = re.sub(r"[ \t]+", " ", s) | |
| s = re.sub(r"\n{3,}", "\n\n", s) | |
| return s.strip() | |
| # ============================ | |
| # File parsing | |
| # ============================ | |
| def extract_text_from_pdf(path: str) -> str: | |
| reader = PdfReader(path) | |
| out = [] | |
| for page in reader.pages: | |
| out.append(page.extract_text() or "") | |
| return "\n".join(out) | |
| def extract_text_from_docx(path: str) -> str: | |
| doc = Document(path) | |
| return "\n".join(p.text for p in doc.paragraphs) | |
| def parse_file_to_text(path: str | None) -> str: | |
| if not path: | |
| return "" | |
| ext = path.split(".")[-1].lower() | |
| if ext == "pdf": | |
| return normalize_text(extract_text_from_pdf(path)) | |
| if ext == "docx": | |
| return normalize_text(extract_text_from_docx(path)) | |
| if ext == "txt": | |
| with open(path, "r", encoding="utf-8", errors="ignore") as f: | |
| return normalize_text(f.read()) | |
| return "" | |
| # ============================ | |
| # Groq chat helper | |
| # ============================ | |
| def groq_chat(user_prompt: str, system_prompt: str, model: str, max_tokens: int) -> str: | |
| resp = client.chat.completions.create( | |
| model=model, | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| temperature=DEFAULT_TEMPERATURE, | |
| max_tokens=max_tokens, | |
| stream=False, | |
| ) | |
| return resp.choices[0].message.content or "" | |
| # ============================ | |
| # Embeddings-based ATS helpers (E5) | |
| # ============================ | |
| BULLET_RE = re.compile(r"^(\-|\*|•|·|–|—||\u2022|\u25cf|\u25aa|\d+[\.\)])\s+") | |
| def extract_requirements(job_text: str, max_items: int = 25) -> List[str]: | |
| lines = [l.strip() for l in (job_text or "").splitlines() if l.strip()] | |
| bullets = [] | |
| for l in lines: | |
| if BULLET_RE.match(l): | |
| item = BULLET_RE.sub("", l).strip() | |
| if 10 <= len(item) <= 220: | |
| bullets.append(item) | |
| if not bullets: | |
| sentences = re.split(r"(?<=[\.\!\?])\s+", (job_text or "").strip()) | |
| for s in sentences: | |
| s = s.strip() | |
| if 12 <= len(s) <= 220: | |
| bullets.append(s) | |
| seen = set() | |
| uniq = [] | |
| for b in bullets: | |
| key = b.lower() | |
| if key not in seen: | |
| seen.add(key) | |
| uniq.append(b) | |
| return uniq[:max_items] | |
| def split_chunks(text: str, max_chars: int = 500, max_chunks: int = 80) -> List[str]: | |
| parts = re.split(r"\n{2,}|(?<=[.!?])\s+", text or "") | |
| chunks, buf = [], "" | |
| for p in (x.strip() for x in parts if x and x.strip()): | |
| if len(buf) + len(p) + 1 <= max_chars: | |
| buf = (buf + " " + p).strip() | |
| else: | |
| if buf: | |
| chunks.append(buf) | |
| buf = p | |
| if buf: | |
| chunks.append(buf) | |
| return chunks[:max_chunks] | |
| def cosine(a: np.ndarray, b: np.ndarray) -> float: | |
| a = a.astype(np.float32) | |
| b = b.astype(np.float32) | |
| na = float(np.linalg.norm(a) + 1e-9) | |
| nb = float(np.linalg.norm(b) + 1e-9) | |
| return float(np.dot(a / na, b / nb)) | |
| def compute_metrics(resume_text: str, job_text: str, thr: float = 0.42) -> Dict[str, Any]: | |
| resume_text = resume_text or "" | |
| job_text = job_text or "" | |
| reqs = extract_requirements(job_text, max_items=25) | |
| model = get_emb_model() | |
| resume_chunks = split_chunks(resume_text, max_chars=550, max_chunks=80) | |
| if not resume_chunks or not reqs: | |
| return { | |
| "score_0_100": 0, | |
| "semantic_similarity": 0.0, | |
| "coverage_ratio": 0.0, | |
| "requirements_total": len(reqs), | |
| "overlaps_top": [], | |
| "gaps_top": [{"requirement": r, "sim": 0.0} for r in reqs[:10]], | |
| } | |
| chunk_embs = model.encode(["passage: " + c for c in resume_chunks], show_progress_bar=False) | |
| req_embs = model.encode(["query: " + r for r in reqs], show_progress_bar=False) | |
| overlaps, gaps = [], [] | |
| for r, re_emb in zip(reqs, req_embs): | |
| best = max(cosine(re_emb, ce) for ce in chunk_embs) | |
| item = {"requirement": r, "sim": round(float(best), 4)} | |
| (overlaps if best >= thr else gaps).append(item) | |
| cov_ratio = len(overlaps) / max(1, len(reqs)) | |
| r_all = model.encode("query: " + resume_text[:6000], show_progress_bar=False) | |
| j_all = model.encode("query: " + job_text[:6000], show_progress_bar=False) | |
| sem = cosine(r_all, j_all) | |
| score = int(round(100.0 * (0.60 * cov_ratio + 0.40 * sem))) | |
| score = max(0, min(100, score)) | |
| overlaps_sorted = sorted(overlaps, key=lambda x: x["sim"], reverse=True)[:12] | |
| gaps_sorted = sorted(gaps, key=lambda x: x["sim"])[:12] | |
| return { | |
| "score_0_100": score, | |
| "semantic_similarity": round(float(sem), 4), | |
| "coverage_ratio": round(float(cov_ratio), 4), | |
| "requirements_total": len(reqs), | |
| "overlaps_top": overlaps_sorted, | |
| "gaps_top": gaps_sorted, | |
| } | |
| def select_context_for_llm(resume_text: str, job_text: str, max_chars: int = 6500) -> Tuple[str, str]: | |
| resume_text = resume_text or "" | |
| job_text = job_text or "" | |
| if len(resume_text) <= max_chars and len(job_text) <= max_chars: | |
| return resume_text, job_text | |
| if not job_text.strip(): | |
| return resume_text[:max_chars], job_text[:max_chars] | |
| model = get_emb_model() | |
| chunks = split_chunks(resume_text, max_chars=650, max_chunks=120) | |
| reqs = extract_requirements(job_text, max_items=20) | |
| queries = reqs if reqs else [job_text[:1200]] | |
| q_embs = model.encode(["query: " + q for q in queries], show_progress_bar=False) | |
| c_embs = model.encode(["passage: " + c for c in chunks], show_progress_bar=False) | |
| scores = [] | |
| for i, ce in enumerate(c_embs): | |
| best = max(cosine(ce, qe) for qe in q_embs) | |
| scores.append((i, best)) | |
| scores.sort(key=lambda x: x[1], reverse=True) | |
| picked = [0] | |
| for i, _ in scores: | |
| if i not in picked: | |
| picked.append(i) | |
| if len(picked) >= 12: | |
| break | |
| picked_sorted = sorted(picked) | |
| resume_sel = "\n".join(chunks[i] for i in picked_sorted) | |
| return resume_sel[:max_chars], job_text[:max_chars] | |
| # ============================ | |
| # Q&A (local transformer) | |
| # ============================ | |
| def build_qa_context(resume_text: str, job_text: str) -> str: | |
| resume_text = normalize_text(resume_text) | |
| job_text = normalize_text(job_text) | |
| parts = [] | |
| if resume_text.strip(): | |
| parts.append("=== RESUME ===\n" + resume_text) | |
| if job_text.strip(): | |
| parts.append("=== JOB DESCRIPTION ===\n" + job_text) | |
| return "\n\n".join(parts).strip() | |
| _WORD_RE = re.compile(r"[A-Za-zА-Яа-я0-9_]{2,}") | |
| def _kw_set(text: str) -> set[str]: | |
| return set(w.lower() for w in _WORD_RE.findall(text or "")) | |
| def pick_chunks_fast(question: str, chunks: List[str], k: int = 8) -> List[str]: | |
| qk = _kw_set(question) | |
| if not qk: | |
| return chunks[:k] | |
| scored = [] | |
| for c in chunks: | |
| ck = _kw_set(c) | |
| scored.append((len(qk & ck), c)) | |
| scored.sort(key=lambda x: x[0], reverse=True) | |
| picked = [c for score, c in scored[:k] if score > 0] | |
| return picked if picked else chunks[:k] | |
| def answer_question_local_qa( | |
| resume_text: str, | |
| job_description_text: str, | |
| question: str, | |
| scope: str, | |
| qa_model: str, | |
| ) -> str: | |
| question = normalize_text(question) | |
| if not question: | |
| return "❗ Введите вопрос." | |
| resume_text = normalize_text(resume_text) | |
| job_description_text = normalize_text(job_description_text) | |
| if scope == "Resume only": | |
| context = resume_text | |
| elif scope == "Job only": | |
| context = job_description_text | |
| else: | |
| context = build_qa_context(resume_text, job_description_text) | |
| if not context.strip(): | |
| return "❗ Нет контекста: загрузите/вставьте резюме и/или вакансию на первой вкладке." | |
| # fast chunk selection (без эмбеддингов) | |
| chunks = split_chunks(context, max_chars=650, max_chunks=120) | |
| picked = pick_chunks_fast(question, chunks, k=8) | |
| context_sel = "\n".join(picked)[:4500] | |
| qa_model_id = qa_model.split(" (", 1)[0].strip() | |
| qa, _ = get_qa(qa_model_id) | |
| # top-k answers to allow heuristic selection | |
| res = qa( | |
| question=question, | |
| context=context_sel, | |
| topk=5, | |
| handle_impossible_answer=True, | |
| ) | |
| candidates = res if isinstance(res, list) else [res] | |
| ql = question.lower() | |
| wants_number = any(w in ql for w in ["сколько", "лет", "возраст", "года", "год"]) | |
| def cand_key(r: Dict[str, Any]) -> tuple: | |
| ans = (r.get("answer") or "") | |
| score = float(r.get("score") or 0.0) | |
| has_digit = bool(re.search(r"\d", ans)) | |
| # prefer digit-containing answers for "how many/age" questions | |
| return (has_digit, score) if wants_number else (True, score) | |
| candidates.sort(key=cand_key, reverse=True) | |
| best = candidates[0] | |
| ans = (best.get("answer") or "").strip() | |
| score = float(best.get("score") or 0.0) | |
| if not ans: | |
| return f"**QA model:** `{qa_model}`\n\nОтвет не найден." | |
| # Postprocess: if question expects number, return the first integer (e.g., "24" from "24 года") | |
| if wants_number: | |
| m = re.search(r"\d{1,3}", ans) | |
| if m: | |
| ans = m.group(0) | |
| evidence = context_sel[:900].replace("\n", " ") | |
| if len(context_sel) > 900: | |
| evidence += "…" | |
| return f""" | |
| <div style="border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px; margin: 12px 0;"> | |
| <div style="font-weight: 700; margin-bottom: 6px;">Q&A (Local Transformer)</div> | |
| <div style="font-size: 12px; color: #6b7280;"> | |
| Model: <code>{qa_model}</code> · Confidence: <code>{score:.3f}</code> | |
| </div> | |
| </div> | |
| ### Answer | |
| {ans} | |
| """.strip() | |
| # ============================ | |
| # Features | |
| # ============================ | |
| def _top1_label(items: List[Tuple[str, float]]) -> str: | |
| return items[0][0] if items else "N/A" | |
| def analyze_resume( | |
| resume_text: str, | |
| job_description_text: str, | |
| with_job: bool, | |
| ) -> Tuple[str, str]: | |
| resume_text = normalize_text(resume_text) | |
| job_description_text = normalize_text(job_description_text) | |
| if not resume_text: | |
| return "", "❗ Загрузите резюме (PDF/DOCX) или вставьте текст резюме." | |
| if with_job and not job_description_text: | |
| return "", "❗ Вставьте вакансию (текстом или файлом) или снимите галочку." | |
| if not with_job: | |
| r_ctx, _ = select_context_for_llm(resume_text, "", max_chars=6500) | |
| prompt = f""" | |
| Проанализируй резюме без вакансии. Дай ответ по-русски в Markdown: | |
| 1) Общая оценка 0..10 | |
| 2) Улучшения по критериям: Impact, Brevity, Style, Sections | |
| 3) Сводный вывод | |
| 4) 3-4 рекомендации с примерами (не выдумывай опыт) | |
| Резюме: | |
| {r_ctx} | |
| """ | |
| groq_md = groq_chat( | |
| prompt, | |
| "You are an expert ATS resume analyzer.", | |
| ANALYZE_MODEL, | |
| DEFAULT_MAX_TOKENS_ANALYZE, | |
| ) | |
| return "", groq_md | |
| metrics = compute_metrics(resume_text, job_description_text, thr=0.42) | |
| r_ctx, j_ctx = select_context_for_llm(resume_text, job_description_text, max_chars=6500) | |
| role_job = zshot_top3(job_description_text, ROLE_LABELS) | |
| role_res = zshot_top3(resume_text, ROLE_LABELS) | |
| lvl_job = zshot_top3(job_description_text, LEVEL_LABELS) | |
| lvl_res = zshot_top3(resume_text, LEVEL_LABELS) | |
| zshot_block_md = f""" | |
| <div style="border: 1px solid #e5e7eb; border-radius: 12px; padding: 12px; margin: 12px 0;"> | |
| <div style="font-weight: 700; margin-bottom: 6px;">Zero-shot классификация (Role/Level)</div> | |
| <div style="font-size: 12px; color: #6b7280; margin-bottom: 10px;"> | |
| Model: <code>{ZSHOT_MODEL_NAME}</code> | |
| </div> | |
| <div style="line-height: 1.5;"> | |
| <div><b>Role (vacancy):</b> {_top1_label(role_job)}</div> | |
| <div><b>Role (resume):</b> {_top1_label(role_res)}</div> | |
| <div style="margin-top: 6px;"><b>Level (vacancy):</b> {_top1_label(lvl_job)}</div> | |
| <div><b>Level (resume):</b> {_top1_label(lvl_res)}</div> | |
| </div> | |
| </div> | |
| """.strip() | |
| enrich = { | |
| "metrics": metrics, | |
| "job_role_top3": [(l, round(s, 4)) for l, s in role_job], | |
| "resume_role_top3": [(l, round(s, 4)) for l, s in role_res], | |
| "job_level_top3": [(l, round(s, 4)) for l, s in lvl_job], | |
| "resume_level_top3": [(l, round(s, 4)) for l, s in lvl_res], | |
| } | |
| prompt = f""" | |
| Ты ATS-аналитик. Проведи строгий анализ резюме под вакансию. | |
| Дай ответ по-русски, в Markdown. | |
| Доп. сигналы (посчитаны автоматически, используй как ориентир): | |
| {json.dumps(enrich, ensure_ascii=False, indent=2)} | |
| Требования: | |
| - Выдай: (1) match-% (2) missing keywords (3) 3-4 рекомендации с примерами (4) 7 правок резюме под вакансию. | |
| - Не выдумывай опыт/технологии, которых нет в выдержках резюме. | |
| Описание вакансии: | |
| {j_ctx} | |
| Выдержки резюме: | |
| {r_ctx} | |
| """ | |
| groq_md = groq_chat( | |
| prompt, | |
| "You are an expert ATS resume analyzer.", | |
| ANALYZE_MODEL, | |
| DEFAULT_MAX_TOKENS_ANALYZE, | |
| ) | |
| return zshot_block_md, groq_md | |
| def generate_cover_letter( | |
| resume_text: str, | |
| job_description_text: str, | |
| letter_type: str = "modern" | |
| ) -> str: | |
| resume_text = normalize_text(resume_text) | |
| job_description_text = normalize_text(job_description_text) | |
| if not resume_text: | |
| return "❗ Сначала загрузите резюме в первой вкладке." | |
| if not job_description_text: | |
| return "❗ Вставьте вакансию (текстом или файлом) в первой вкладке." | |
| # Проверка наличия типа письма | |
| if letter_type not in COVER_LETTER_TYPES: | |
| letter_type = "modern" | |
| letter_config = COVER_LETTER_TYPES[letter_type] | |
| r_ctx, j_ctx = select_context_for_llm(resume_text, job_description_text, max_chars=6500) | |
| prompt = letter_config["prompt_template"].format(r_ctx=r_ctx, j_ctx=j_ctx) | |
| return groq_chat(prompt, "You are an expert in writing tailored cover letters.", LETTER_MODEL, DEFAULT_MAX_TOKENS_LETTER) | |
| # ============================ | |
| # UI | |
| # ============================ | |
| with gr.Blocks() as demo: | |
| gr.HTML(TITLE) | |
| with gr.Tab("Resume Analyzer"): | |
| gr.HTML(RESUME_ANALYZER_INSTRUCTIONS) | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown("### Резюме") | |
| resume_file = gr.File( | |
| label="Upload Resume (PDF/DOCX)", | |
| file_types=[".pdf", ".docx"], | |
| type="filepath", | |
| ) | |
| resume_content = gr.Textbox( | |
| label="Parsed Resume Content", | |
| lines=16, | |
| placeholder="Текст резюме появится здесь после загрузки файла (или можно вставить вручную).", | |
| ) | |
| with gr.Column(): | |
| gr.Markdown("### Вакансия") | |
| with_job_description = gr.Checkbox( | |
| label="Analyze with Job Description", | |
| value=True, | |
| ) | |
| job_file = gr.File( | |
| label="Upload Job Description (PDF/DOCX/TXT)", | |
| file_types=[".pdf", ".docx", ".txt"], | |
| type="filepath", | |
| ) | |
| job_description = gr.Textbox( | |
| label="Job Description (text)", | |
| lines=16, | |
| placeholder="Вставьте сюда описание вакансии... или загрузите файл выше.", | |
| ) | |
| analyze_btn = gr.Button("Analyze Resume") | |
| # Separate block above Groq output | |
| zshot_block = gr.Markdown(visible=True) | |
| output = gr.Markdown() | |
| with gr.Tab("Cover Letter Generator"): | |
| gr.HTML(COVER_LETTER_INSTRUCTIONS) | |
| # Обновление описания при смене типа | |
| def update_type_description(letter_type_key: str): | |
| config = COVER_LETTER_TYPES.get(letter_type_key, COVER_LETTER_TYPES["modern"]) | |
| return gr.update(value=config["description"]) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| letter_type = gr.Dropdown( | |
| choices=[(config["name"], key) for key, config in COVER_LETTER_TYPES.items()], | |
| value="modern", | |
| label="Тип сопроводительного письма", | |
| info="Выберите стиль письма" | |
| ) | |
| # Превью описания стиля | |
| type_description = gr.Markdown( | |
| value=COVER_LETTER_TYPES["modern"]["description"], | |
| label="Описание стиля" | |
| ) | |
| generate_cl_btn = gr.Button("Generate Cover Letter", variant="primary") | |
| letter_type.change( | |
| update_type_description, | |
| inputs=[letter_type], | |
| outputs=[type_description] | |
| ) | |
| # Отдельный блок для вывода с информацией о стиле | |
| cover_letter_header = gr.Markdown("### Сгенерированное письмо:") | |
| cover_letter_style_info = gr.Markdown(visible=False) | |
| cover_letter_output = gr.Markdown() | |
| # Функция для отображения информации о стиле | |
| def show_cover_letter_with_style(letter_text: str, letter_type_key: str): | |
| if letter_text.startswith("❗"): | |
| return gr.update(value=""), gr.update(visible=False), gr.update(value=letter_text) | |
| config = COVER_LETTER_TYPES.get(letter_type_key, COVER_LETTER_TYPES["modern"]) | |
| style_info = f""" | |
| <div style="border-left: 4px solid #3b82f6; background-color: #f0f9ff; padding: 12px; border-radius: 6px; margin-bottom: 16px;"> | |
| <div style="font-weight: 600; color: #1e40af;">Стиль: {config['name']}</div> | |
| <div style="font-size: 14px; color: #374151; margin-top: 4px;">{config['description']}</div> | |
| </div> | |
| """ | |
| return gr.update(value=style_info), gr.update(visible=True), gr.update(value=letter_text) | |
| generate_cl_btn.click( | |
| show_cover_letter_with_style, | |
| inputs=[cover_letter_output, letter_type], | |
| outputs=[cover_letter_style_info, cover_letter_style_info, cover_letter_output] | |
| ).then( | |
| generate_cover_letter, | |
| inputs=[resume_content, job_description, letter_type], | |
| outputs=[cover_letter_output] | |
| ) | |
| # NEW TAB: local transformer Q&A | |
| with gr.Tab("Q&A (Local Transformer)"): | |
| gr.HTML(QA_INSTRUCTIONS) | |
| qa_scope = gr.Radio( | |
| choices=["Resume+Job", "Resume only", "Job only"], | |
| value="Resume+Job", | |
| label="Context scope", | |
| ) | |
| qa_question = gr.Textbox( | |
| label="Question", | |
| lines=2, | |
| placeholder="Например: Какие ключевые навыки указаны? Подходит ли опыт под требование X? Сколько лет опыта с Kotlin?", | |
| ) | |
| qa_btn = gr.Button("Answer (Local QA)") | |
| qa_out = gr.Markdown() | |
| qa_model = gr.Radio( | |
| choices=[ | |
| f"{QA_MODEL_MULTI} (multilingual)", | |
| f"{QA_MODEL_RU} (ru)", | |
| ], | |
| value=f"{QA_MODEL_RU} (ru)", | |
| label="QA model", | |
| ) | |
| qa_btn.click( | |
| answer_question_local_qa, | |
| inputs=[resume_content, job_description, qa_question, qa_scope, qa_model], | |
| outputs=[qa_out], | |
| ) | |
| def update_job_inputs_visibility(with_job: bool): | |
| return gr.update(visible=with_job), gr.update(visible=with_job) | |
| with_job_description.change( | |
| update_job_inputs_visibility, | |
| inputs=[with_job_description], | |
| outputs=[job_description, job_file], | |
| ) | |
| # Upload handlers: filepath -> textbox | |
| resume_file.upload(parse_file_to_text, inputs=[resume_file], outputs=[resume_content]) | |
| job_file.upload(parse_file_to_text, inputs=[job_file], outputs=[job_description]) | |
| # analyze_resume returns 2 outputs | |
| analyze_btn.click( | |
| analyze_resume, | |
| inputs=[resume_content, job_description, with_job_description], | |
| outputs=[zshot_block, output], | |
| ) | |
| generate_cl_btn.click( | |
| generate_cover_letter, | |
| inputs=[resume_content, job_description], | |
| outputs=[cover_letter_output], | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue() | |
| demo.launch() | |