Spaces:
Running
Running
| import os | |
| import re | |
| import time | |
| import json | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, List, Optional | |
| from collections import Counter | |
| import requests | |
| import gradio as gr | |
| # ======================= | |
| # .env + проверка OLLAMA_API_KEY | |
| # ======================= | |
| try: | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| except Exception: | |
| pass | |
| OLLAMA_API_KEY_ENV = "OLLAMA_API_KEY" | |
| OLLAMA_API_KEY = os.environ.get(OLLAMA_API_KEY_ENV) | |
| if not OLLAMA_API_KEY: | |
| raise RuntimeError( | |
| f"Переменная окружения {OLLAMA_API_KEY_ENV} не задана.\n" | |
| f"В Hugging Face Spaces её нужно добавить в Settings → Variables/Secrets." | |
| ) | |
| # ======================= | |
| # smolagents + Ollama Cloud модель | |
| # ======================= | |
| from ollama import Client | |
| from smolagents import Model, ChatMessage, tool, ToolCallingAgent | |
| class OllamaCloudModel(Model): | |
| """ | |
| Адаптер Ollama Cloud к интерфейсу smolagents.Model. | |
| generate(...) -> ChatMessage (НЕ поток). | |
| """ | |
| def __init__( | |
| self, | |
| model_id: str = "gpt-oss:120b", | |
| host: str = "https://ollama.com", | |
| api_key_env: str = "OLLAMA_API_KEY", | |
| **kwargs, | |
| ): | |
| super().__init__(model_id=model_id, **kwargs) | |
| api_key = os.environ.get(api_key_env) | |
| if not api_key: | |
| raise ValueError(f"Не найден {api_key_env} в окружении") | |
| self.client = Client( | |
| host=host, | |
| headers={"Authorization": f"Bearer {api_key}"}, | |
| ) | |
| def _to_text(self, content) -> str: | |
| if content is None: | |
| return "" | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| parts = [] | |
| for p in content: | |
| if isinstance(p, dict): | |
| parts.append(p.get("text", "")) | |
| else: | |
| parts.append(str(p)) | |
| return "".join(parts) | |
| return str(content) | |
| def generate(self, messages, stop_sequences=None, **kwargs) -> ChatMessage: | |
| """ | |
| messages: список dict или ChatMessage; возвращаем ChatMessage. | |
| ToolCallingAgent ожидает именно такой интерфейс. | |
| """ | |
| if isinstance(messages, str): | |
| msgs = [{"role": "user", "content": messages}] | |
| else: | |
| msgs: List[Dict[str, str]] = [] | |
| for m in messages: | |
| if isinstance(m, dict): | |
| role = m.get("role", "user") | |
| content = self._to_text(m.get("content", "")) | |
| else: | |
| role = getattr(m, "role", "user") | |
| content = self._to_text(getattr(m, "content", "")) | |
| msgs.append({"role": role, "content": content}) | |
| resp = self.client.chat( | |
| model=self.model_id, | |
| messages=msgs, | |
| stream=False, | |
| ) | |
| text = self._to_text((resp.get("message") or {}).get("content", "")) | |
| if stop_sequences: | |
| for s in stop_sequences: | |
| if not s: | |
| continue | |
| idx = text.find(s) | |
| if idx != -1: | |
| text = text[:idx] | |
| break | |
| return ChatMessage(role="assistant", content=text) | |
| # ======================= | |
| # Клиент HH.ru | |
| # ======================= | |
| class HHClient: | |
| base_url: str = "https://api.hh.ru" | |
| user_agent: str = "hh-skill-agent/0.1 (educational)" | |
| def _headers(self) -> Dict[str, str]: | |
| return { | |
| "User-Agent": self.user_agent, | |
| "Accept": "application/json", | |
| } | |
| def search_vacancies( | |
| self, | |
| text: Optional[str] = None, | |
| area: int = 113, | |
| page: int = 0, | |
| per_page: int = 50, | |
| order_by: str = "publication_time", | |
| extra_params: Optional[Dict[str, Any]] = None, | |
| ) -> Dict[str, Any]: | |
| params: Dict[str, Any] = { | |
| "area": area, | |
| "page": page, | |
| "per_page": per_page, | |
| "order_by": order_by, | |
| } | |
| if text: | |
| params["text"] = text | |
| if extra_params: | |
| params.update(extra_params) | |
| resp = requests.get( | |
| f"{self.base_url}/vacancies", | |
| params=params, | |
| headers=self._headers(), | |
| timeout=20, | |
| ) | |
| resp.raise_for_status() | |
| return resp.json() | |
| def get_vacancy(self, vacancy_id: str) -> Dict[str, Any]: | |
| resp = requests.get( | |
| f"{self.base_url}/vacancies/{vacancy_id}", | |
| headers=self._headers(), | |
| timeout=20, | |
| ) | |
| resp.raise_for_status() | |
| return resp.json() | |
| def suggest_areas(self, text: str, per_page: int = 5) -> List[Dict[str, Any]]: | |
| resp = requests.get( | |
| f"{self.base_url}/suggests/areas", | |
| params={"text": text, "per_page": per_page}, | |
| headers=self._headers(), | |
| timeout=10, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| return data.get("items") or [] | |
| hh = HHClient() | |
| # ======================= | |
| # Утилиты извлечения навыков | |
| # ======================= | |
| def _normalize_skill(s: str) -> str: | |
| s = s.strip() | |
| s = re.sub(r"\s+", " ", s) | |
| return s.lower() | |
| def _skills_from_key_skills(v: Dict[str, Any]) -> List[str]: | |
| ks = v.get("key_skills") or [] | |
| out: List[str] = [] | |
| for item in ks: | |
| name = (item or {}).get("name") | |
| if name: | |
| out.append(name) | |
| return out | |
| def _skills_from_text(text: str) -> List[str]: | |
| if not text: | |
| return [] | |
| text = re.sub(r"<[^>]+>", "\n", text) | |
| candidates = re.split(r"[•\n;,/]| - ", text) | |
| res: List[str] = [] | |
| for c in candidates: | |
| c = c.strip() | |
| if 2 <= len(c) <= 40 and re.search(r"[A-Za-zА-Яа-я]", c): | |
| res.append(c) | |
| return res | |
| def _extract_skills(v: Dict[str, Any]) -> List[str]: | |
| skills: List[str] = [] | |
| skills.extend(_skills_from_key_skills(v)) | |
| snippet = v.get("snippet") or {} | |
| skills.extend(_skills_from_text(snippet.get("requirement") or "")) | |
| skills.extend(_skills_from_text(snippet.get("responsibility") or "")) | |
| if not skills: | |
| skills.extend(_skills_from_text(v.get("description") or "")) | |
| return skills | |
| def _safe_get_full(vacancy_id: str, retries: int = 2) -> Optional[Dict[str, Any]]: | |
| for attempt in range(retries + 1): | |
| try: | |
| return hh.get_vacancy(vacancy_id) | |
| except requests.HTTPError as e: | |
| status = getattr(e.response, "status_code", None) | |
| if status in (429, 503) and attempt < retries: | |
| time.sleep(0.5 * (attempt + 1)) | |
| continue | |
| raise | |
| except requests.RequestException: | |
| if attempt < retries: | |
| time.sleep(0.5 * (attempt + 1)) | |
| continue | |
| raise | |
| return None | |
| # ======================= | |
| # Инструменты smolagents | |
| # ======================= | |
| def fetch_hh_vacancies_full( | |
| text: str, | |
| area: int = 113, | |
| pages: int = 2, | |
| per_page: Optional[int] = 50, | |
| max_items: int = 30, | |
| ) -> str: | |
| """ | |
| Ищет вакансии на hh.ru по текстовому запросу и подтягивает полные данные | |
| по первым N найденным вакансиям. | |
| Args: | |
| text: Название роли/вакансии или поисковый запрос. | |
| area: ID региона поиска (113 = Россия). | |
| pages: Сколько страниц поиска просмотреть (1..5). | |
| per_page: Сколько вакансий запрашивать на страницу (1..100). | |
| max_items: Сколько полных вакансий скачать в деталях (1..100). | |
| Returns: | |
| JSON-строка со списком полных вакансий. | |
| """ | |
| items: List[Dict[str, Any]] = [] | |
| if per_page is None or per_page <= 0: | |
| per_page = 50 | |
| pages = max(1, min(int(pages), 5)) | |
| per_page = max(1, min(int(per_page), 100)) | |
| max_items = max(1, min(int(max_items), 100)) | |
| for page in range(pages): | |
| data = hh.search_vacancies( | |
| text=text or None, | |
| area=area, | |
| page=page, | |
| per_page=per_page, | |
| ) | |
| items.extend(data.get("items") or []) | |
| if page + 1 >= (data.get("pages") or 0): | |
| break | |
| time.sleep(0.2) | |
| ids: List[str] = [] | |
| for it in items: | |
| vid = it.get("id") | |
| if vid: | |
| ids.append(str(vid)) | |
| if len(ids) >= max_items: | |
| break | |
| full: List[Dict[str, Any]] = [] | |
| for vid in ids: | |
| v = _safe_get_full(vid) | |
| if v: | |
| full.append( | |
| { | |
| "id": v.get("id"), | |
| "name": v.get("name"), | |
| "description": v.get("description"), | |
| "key_skills": v.get("key_skills"), | |
| "snippet": v.get("snippet"), | |
| "experience": v.get("experience"), | |
| "employment": v.get("employment"), | |
| "schedule": v.get("schedule"), | |
| "area": v.get("area"), | |
| "employer": v.get("employer"), | |
| } | |
| ) | |
| time.sleep(0.1) | |
| return json.dumps(full, ensure_ascii=False) | |
| def aggregate_hh_skills(vacancies_json: str, top_n: int = 20) -> str: | |
| """ | |
| Агрегирует навыки из списка вакансий и возвращает топ самых частотных. | |
| Args: | |
| vacancies_json: JSON-строка со списком вакансий. | |
| top_n: Количество навыков в топе. | |
| Returns: | |
| JSON-строка: | |
| { | |
| "total_vacancies": int, | |
| "top_skills": [{"skill": str, "count": int}, ...] | |
| } | |
| """ | |
| try: | |
| vacancies = json.loads(vacancies_json) | |
| except Exception: | |
| vacancies = [] | |
| counter: Counter[str] = Counter() | |
| for v in vacancies or []: | |
| if not isinstance(v, dict): | |
| continue | |
| for s in _extract_skills(v): | |
| ns = _normalize_skill(s) | |
| if ns: | |
| counter[ns] += 1 | |
| top = [ | |
| {"skill": skill, "count": count} | |
| for skill, count in counter.most_common(int(top_n)) | |
| ] | |
| payload = {"total_vacancies": len(vacancies or []), "top_skills": top} | |
| return json.dumps(payload, ensure_ascii=False) | |
| # ======================= | |
| # Агент smolagents | |
| # ======================= | |
| def build_agent(model_id: str = "gpt-oss:120b") -> ToolCallingAgent: | |
| model = OllamaCloudModel(model_id=model_id) | |
| agent = ToolCallingAgent( | |
| tools=[fetch_hh_vacancies_full, aggregate_hh_skills], | |
| model=model, | |
| max_steps=6, | |
| add_base_tools=False, | |
| ) | |
| return agent | |
| def _needs_postprocess(agent_output: str) -> bool: | |
| text = agent_output.strip() | |
| if not text: | |
| return True | |
| if '"name":"fetch_hh_vacancies_full"' in text or '"name":"aggregate_hh_skills"' in text: | |
| return True | |
| if text.startswith("{") and "vacancies" in text and "top_n" in text: | |
| return True | |
| if len(text) < 800: | |
| return True | |
| return False | |
| def run_job_insights( | |
| job_title: str, | |
| area: int = 113, | |
| pages: int = 2, | |
| max_items: int = 30, | |
| top_n: int = 20, | |
| model_id: str = "gpt-oss:120b", | |
| extra_notes: str = "", | |
| ) -> (str, str): | |
| """ | |
| 1) Запускает smolagents-агента (ToolCallingAgent) с инструментами hh.ru. | |
| 2) Берёт его сырой вывод (где есть вызовы инструментов и JSON). | |
| 3) Вторым вызовом LLM делает из этого вывода аккуратный Markdown-документ. | |
| """ | |
| agent = build_agent(model_id=model_id) | |
| safe_job_title = job_title.replace('"', '\\"') | |
| notes_part = ( | |
| f"\nДополнительные пожелания к документу: {extra_notes}\n" | |
| if extra_notes.strip() | |
| else "" | |
| ) | |
| task = f""" | |
| Ты — агент smolagents, у тебя есть два инструмента: | |
| 1) fetch_hh_vacancies_full(text, area, pages, per_page, max_items) | |
| 2) aggregate_hh_skills(vacancies_json, top_n) | |
| Твоя цель: на основе реальных данных hh.ru по роли "{safe_job_title}" | |
| подготовить подробное руководство по подготовке к собеседованию. | |
| Важно: | |
| - ОБЯЗАТЕЛЬНО используй эти инструменты, не придумывай вакансии и навыки с нуля. | |
| - НИКОГДА не выводи в финальном ответе JSON с логами, результатами инструментов | |
| или названиями инструментов. Это только внутренняя работа. | |
| - В финальном ответе должен быть только аккуратный текст в формате Markdown. | |
| Параметры данных: | |
| - area (регион hh.ru) = {area} | |
| - pages (страниц для выборки) = {pages} | |
| - max_items (максимум вакансий для анализа) = {max_items} | |
| - top_n (размер топа навыков) = {top_n} | |
| {notes_part} | |
| Структура финального ответа: | |
| ## 0. Краткий лог данных | |
| ## 1. Общее описание роли | |
| ## 2. Основные обязанности и зона ответственности | |
| ## 3. Ключевые технические навыки и технологии (таблица) | |
| ## 4. Сопутствующие компетенции (soft skills, доменная экспертиза) | |
| ## 5. Примерные вопросы для собеседования | |
| ## 6. План подготовки к собеседованию | |
| ## 7. Типичные ошибки кандидатов и рекомендации | |
| Пиши по-русски. Используй только Markdown. | |
| """ | |
| # 1) Сырой вывод агента (с вызовами инструментов и JSON) | |
| agent_raw_output = agent.run(task) | |
| # 2) Всегда прогоняем через пост-обработку в отдельный LLM-вызов | |
| model = OllamaCloudModel(model_id=model_id) | |
| post_prompt = f""" | |
| Ниже приведён сырой текст, который вернул агент smolagents при работе с | |
| инструментами hh.ru. В нём есть JSON, логи вызовов инструментов и прочий служебный текст. | |
| Твоя задача — ИСПОЛЬЗОВАТЬ ЭТОТ ТЕКСТ КАК ИСТОЧНИК ДАННЫХ, | |
| но НЕ ПЕРЕПЕЧАТЫВАТЬ лог. Вместо этого: | |
| - Сформируй один аккуратный документ в формате Markdown по структуре: | |
| ## 0. Краткий лог данных | |
| ## 1. Общее описание роли | |
| ## 2. Основные обязанности и зона ответственности | |
| ## 3. Ключевые технические навыки и технологии (таблица | Навык | Зачем нужен | Блок |) | |
| ## 4. Сопутствующие компетенции (soft skills, доменная экспертиза) | |
| ## 5. Примерные вопросы для собеседования | |
| ## 6. План подготовки к собеседованию | |
| ## 7. Типичные ошибки кандидатов и рекомендации | |
| Условия: | |
| - Не включай в ответ JSON, сырой лог и названия инструментов. | |
| - Пиши по-русски, ориентируясь на роль: "{job_title}". | |
| Вот сырой вывод агента: | |
| ```text | |
| {agent_raw_output} | |
| """ | |
| msg = model.generate([{"role": "user", "content": post_prompt}]) | |
| final_doc_md = msg.content | |
| return final_doc_md, agent_raw_output | |
| # ======================= | |
| # Топ IT-специальностей | |
| # ======================= | |
| TOP_IT_SPECIALTIES: List[Dict[str, Any]] = [] | |
| SPECIALTY_LABEL_TO_OBJ: Dict[str, Dict[str, Any]] = {} | |
| def fetch_top_it_specialties(area: int = 113, max_roles: int = 20) -> List[Dict[str, Any]]: | |
| IT_SEARCH_QUERIES = [ | |
| "Python разработчик", | |
| "Java разработчик", | |
| "Golang разработчик", | |
| "C++ разработчик", | |
| "C# разработчик", | |
| "JavaScript разработчик", | |
| "Frontend разработчик", | |
| "Backend разработчик", | |
| "Fullstack разработчик", | |
| "DevOps инженер", | |
| "SRE инженер", | |
| "Data Scientist", | |
| "Data Engineer", | |
| "ML Engineer", | |
| "QA инженер", | |
| "Автотестировщик", | |
| "Системный аналитик", | |
| "Бизнес-аналитик", | |
| "Product Manager", | |
| "Project Manager", | |
| ] | |
| scored: List[Dict[str, Any]] = [] | |
| for q in IT_SEARCH_QUERIES: | |
| try: | |
| res = hh.search_vacancies( | |
| text=q, | |
| area=area, | |
| page=0, | |
| per_page=1, | |
| ) | |
| found = int(res.get("found", 0)) | |
| if found > 0: | |
| scored.append({"id": q, "name": q, "found": found}) | |
| except Exception: | |
| continue | |
| time.sleep(0.15) | |
| scored.sort(key=lambda x: x["found"], reverse=True) | |
| return scored[:max_roles] | |
| def init_specialties() -> List[str]: | |
| global TOP_IT_SPECIALTIES, SPECIALTY_LABEL_TO_OBJ | |
| if not TOP_IT_SPECIALTIES: | |
| try: | |
| TOP_IT_SPECIALTIES = fetch_top_it_specialties(area=113, max_roles=20) | |
| except Exception as e: | |
| print("Ошибка при получении топ IT-специальностей hh.ru:", e) | |
| TOP_IT_SPECIALTIES = [] | |
| SPECIALTY_LABEL_TO_OBJ = {} | |
| labels: List[str] = [] | |
| for spec in TOP_IT_SPECIALTIES: | |
| label = f"{spec['name']} — {spec['found']} вакансий" | |
| SPECIALTY_LABEL_TO_OBJ[label] = spec | |
| labels.append(label) | |
| return labels | |
| def refresh_specialties() -> List[str]: | |
| global TOP_IT_SPECIALTIES, SPECIALTY_LABEL_TO_OBJ | |
| TOP_IT_SPECIALTIES = [] | |
| return init_specialties() | |
| SPECIALTY_CHOICES = init_specialties() | |
| # ======================= | |
| # Топ регионов по выбранной роли | |
| # ======================= | |
| CURRENT_REGIONS: List[Dict[str, Any]] = [] | |
| REGION_LABEL_TO_OBJ: Dict[str, Dict[str, Any]] = {} | |
| CURRENT_REGION_ROLE: str = "" | |
| def _flatten_area_cluster_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| result: List[Dict[str, Any]] = [] | |
| for it in items or []: | |
| name = it.get("name") | |
| count = it.get("count") or 0 | |
| url = it.get("url") or "" | |
| area_id = None | |
| m = re.search(r"area=(\d+)", url) | |
| if m: | |
| area_id = int(m.group(1)) | |
| if name and area_id and count: | |
| result.append({"id": area_id, "name": name, "count": int(count)}) | |
| if it.get("items"): | |
| result.extend(_flatten_area_cluster_items(it.get("items"))) | |
| return result | |
| def fetch_top_regions_for_role(job_title: str, max_regions: int = 20) -> List[Dict[str, Any]]: | |
| try: | |
| data = hh.search_vacancies( | |
| text=job_title, | |
| area=113, | |
| page=0, | |
| per_page=1, | |
| extra_params={"clusters": "true"}, | |
| ) | |
| except Exception as e: | |
| print("Ошибка при запросе кластеров регионов:", e) | |
| return [] | |
| clusters = data.get("clusters") or [] | |
| area_cluster = None | |
| for c in clusters: | |
| if c.get("id") == "area": | |
| area_cluster = c | |
| break | |
| if not area_cluster: | |
| return [] | |
| flat_items = _flatten_area_cluster_items(area_cluster.get("items") or []) | |
| flat_items.sort(key=lambda x: x["count"], reverse=True) | |
| return flat_items[:max_regions] | |
| def build_regions_for_role(job_title: str) -> List[str]: | |
| global CURRENT_REGIONS, REGION_LABEL_TO_OBJ, CURRENT_REGION_ROLE | |
| CURRENT_REGION_ROLE = job_title | |
| try: | |
| CURRENT_REGIONS = fetch_top_regions_for_role(job_title, max_regions=20) | |
| except Exception as e: | |
| print("Ошибка при получении топ регионов:", e) | |
| CURRENT_REGIONS = [] | |
| REGION_LABEL_TO_OBJ = {} | |
| labels: List[str] = [] | |
| for reg in CURRENT_REGIONS: | |
| label = f"{reg['name']} — {reg['count']} вакансий" | |
| REGION_LABEL_TO_OBJ[label] = reg | |
| labels.append(label) | |
| return labels | |
| def refresh_regions_for_role(job_title: str) -> List[str]: | |
| return build_regions_for_role(job_title) | |
| DEFAULT_ROLE_NAME = None | |
| if SPECIALTY_CHOICES: | |
| first_label = SPECIALTY_CHOICES[0] | |
| first_spec = SPECIALTY_LABEL_TO_OBJ.get(first_label) | |
| if first_spec: | |
| DEFAULT_ROLE_NAME = first_spec["name"] | |
| REGION_CHOICES = build_regions_for_role(DEFAULT_ROLE_NAME or "Python разработчик") | |
| # ======================= | |
| # Вспомогательные функции для Gradio | |
| # ======================= | |
| def _slugify(text: str) -> str: | |
| text = text.strip().lower() | |
| text = re.sub(r"[^a-zA-Z0-9а-яА-Я]+", "_", text) | |
| text = re.sub(r"_+", "_", text) | |
| return text.strip("_") or "role" | |
| def _resolve_area_id( | |
| job_title: str, | |
| selected_region_label: Optional[str], | |
| manual_region_name: str, | |
| ) -> int: | |
| manual_region_name = (manual_region_name or "").strip() | |
| if manual_region_name: | |
| try: | |
| items = hh.suggest_areas(manual_region_name, per_page=5) | |
| if items: | |
| first = items[0] | |
| area_id = int(first["id"]) | |
| print(f"Ручной регион '{manual_region_name}' -> area_id={area_id}") | |
| return area_id | |
| except Exception as e: | |
| print("Ошибка поиска региона по названию:", e) | |
| if selected_region_label and selected_region_label in REGION_LABEL_TO_OBJ: | |
| return int(REGION_LABEL_TO_OBJ[selected_region_label]["id"]) | |
| return 113 | |
| def gradio_run_agent( | |
| selected_specialty_label: str, | |
| custom_role: str, | |
| selected_region_label: str, | |
| manual_region_name: str, | |
| pages: float, | |
| max_items: float, | |
| top_n: float, | |
| model_id: str, | |
| extra_notes: str, | |
| ): | |
| pages_int = int(pages) | |
| max_items_int = int(max_items) | |
| top_n_int = int(top_n) | |
| custom_role = (custom_role or "").strip() | |
| job_title: Optional[str] = None | |
| if custom_role: | |
| job_title = custom_role | |
| else: | |
| spec_obj = SPECIALTY_LABEL_TO_OBJ.get(selected_specialty_label) | |
| if spec_obj: | |
| job_title = spec_obj["name"] | |
| else: | |
| yield "Ошибка: не выбрана специальность и не указано собственное название роли.", None, "" | |
| return | |
| area_id = _resolve_area_id(job_title, selected_region_label, manual_region_name) | |
| status_md = ( | |
| "### ⚙️ Агент думает...\n\n" | |
| f"**Роль:** {job_title}\n\n" | |
| f"**Регион (area_id):** {area_id}\n\n" | |
| "Ожидайте, агент сейчас вызывает инструменты hh.ru и агрегирует навыки." | |
| ) | |
| yield status_md, None, "" | |
| try: | |
| final_md, agent_log = run_job_insights( | |
| job_title=job_title, | |
| area=area_id, | |
| pages=pages_int, | |
| max_items=max_items_int, | |
| top_n=top_n_int, | |
| model_id=model_id.strip() or "gpt-oss:120b", | |
| extra_notes=extra_notes, | |
| ) | |
| except Exception as e: | |
| yield f"Во время выполнения возникла ошибка:\n\n{e}", None, f"Ошибка: {e}" | |
| return | |
| slug = _slugify(job_title) | |
| filename = f"hh_agent_{slug}.md" | |
| with open(filename, "w", encoding="utf-8") as f: | |
| f.write(final_md) | |
| yield final_md, filename, agent_log | |
| def gradio_refresh_dropdown_specialties(): | |
| labels = refresh_specialties() | |
| value = labels[0] if labels else None | |
| return gr.update(choices=labels, value=value) | |
| def _role_from_inputs(selected_specialty_label: str, custom_role: str) -> str: | |
| custom_role = (custom_role or "").strip() | |
| if custom_role: | |
| return custom_role | |
| spec = SPECIALTY_LABEL_TO_OBJ.get(selected_specialty_label) | |
| return spec["name"] if spec else "Python разработчик" | |
| def gradio_refresh_regions(selected_specialty_label: str, custom_role: str): | |
| job_title = _role_from_inputs(selected_specialty_label, custom_role) | |
| labels = refresh_regions_for_role(job_title) | |
| value = labels[0] if labels else None | |
| return gr.update(choices=labels, value=value) | |
| # ======================= | |
| # Gradio UI (без кастомных стилей) | |
| # ======================= | |
| with gr.Blocks() as demo: | |
| gr.Markdown( | |
| """ | |
| # HH Skill & Interview Prep Agent | |
| **smolagents + Ollama Cloud + hh.ru** | |
| Агент: | |
| 1. Берёт реальные вакансии с hh.ru. | |
| 2. Инструментами `fetch_hh_vacancies_full` и `aggregate_hh_skills` собирает топ-навыки. | |
| 3. Формирует подробный Markdown-документ для подготовки к собеседованию. | |
| """ | |
| ) | |
| with gr.Tab("Анализ роли и подготовка к собеседованию"): | |
| # Первая "карточка" — выбор роли | |
| with gr.Row(): | |
| with gr.Column(): | |
| with gr.Group(): | |
| gr.Markdown("### 🎯 Роль / специальность") | |
| specialty_dropdown = gr.Dropdown( | |
| label="IT-специальность (топ-20 по количеству вакансий на hh.ru)", | |
| choices=SPECIALTY_CHOICES, | |
| value=SPECIALTY_CHOICES[0] if SPECIALTY_CHOICES else None, | |
| interactive=True, | |
| ) | |
| refresh_specs_button = gr.Button("Обновить список специализаций") | |
| custom_role = gr.Textbox( | |
| label="Свой вариант роли (опционально)", | |
| placeholder=( | |
| "Например: Senior Python разработчик. " | |
| "Если заполнено, значение из списка выше игнорируется." | |
| ), | |
| ) | |
| # Вторая "карточка" — регион | |
| with gr.Column(): | |
| with gr.Group(): | |
| gr.Markdown("### 🌍 Регион поиска") | |
| region_dropdown = gr.Dropdown( | |
| label="Регион (топ-20 по числу вакансий для выбранной роли)", | |
| choices=REGION_CHOICES, | |
| value=REGION_CHOICES[0] if REGION_CHOICES else None, | |
| interactive=True, | |
| ) | |
| manual_region_input = gr.Textbox( | |
| label="Ручной ввод региона (по названию, опционально)", | |
| placeholder="Например: Москва, Санкт-Петербург, Казань", | |
| info=( | |
| "Если указано, выполняется поиск через /suggests/areas и " | |
| "используется найденный регион независимо от списка выше." | |
| ), | |
| ) | |
| # Третья "карточка" — параметры анализа и модель | |
| with gr.Row(): | |
| with gr.Column(): | |
| with gr.Group(): | |
| gr.Markdown("### ⚙️ Параметры анализа") | |
| pages_slider = gr.Slider( | |
| label="Количество страниц вакансий для анализа", | |
| minimum=1, | |
| maximum=5, | |
| step=1, | |
| value=2, | |
| ) | |
| max_items_slider = gr.Slider( | |
| label="Максимум вакансий для детального анализа", | |
| minimum=5, | |
| maximum=50, | |
| step=5, | |
| value=30, | |
| ) | |
| top_n_slider = gr.Slider( | |
| label="Количество ключевых навыков в топе (top_n)", | |
| minimum=5, | |
| maximum=40, | |
| step=1, | |
| value=20, | |
| ) | |
| with gr.Column(): | |
| with gr.Group(): | |
| gr.Markdown("### 🧠 Модель и пожелания") | |
| model_id_input = gr.Textbox( | |
| label="Ollama Cloud модель", | |
| value="gpt-oss:120b", | |
| placeholder="Например: gpt-oss:120b", | |
| ) | |
| extra_notes_input = gr.Textbox( | |
| label="Дополнительные пожелания к документу (опционально)", | |
| placeholder=( | |
| "Например: сделать акцент на микросервисной архитектуре, " | |
| "Kubernetes, BI-инструментах и т.п." | |
| ), | |
| lines=4, | |
| ) | |
| # Четвёртая "карточка" — запуск и результаты | |
| with gr.Column(): | |
| with gr.Group(): | |
| run_button = gr.Button( | |
| "Сгенерировать документ по специальности / роли", | |
| ) | |
| output_md = gr.Markdown(label="📘 Результат (Markdown)") | |
| output_file = gr.File(label="⬇️ Скачать .md документ") | |
| agent_log_md = gr.Markdown(label="🧩 Сырой вывод агента (лог)") | |
| # Логика кнопок | |
| run_button.click( | |
| fn=gradio_run_agent, | |
| inputs=[ | |
| specialty_dropdown, | |
| custom_role, | |
| region_dropdown, | |
| manual_region_input, | |
| pages_slider, | |
| max_items_slider, | |
| top_n_slider, | |
| model_id_input, | |
| extra_notes_input, | |
| ], | |
| outputs=[output_md, output_file, agent_log_md], | |
| ) | |
| refresh_specs_button.click( | |
| fn=gradio_refresh_dropdown_specialties, | |
| inputs=None, | |
| outputs=specialty_dropdown, | |
| ) | |
| # Динамическое обновление регионов при смене роли | |
| specialty_dropdown.change( | |
| fn=gradio_refresh_regions, | |
| inputs=[specialty_dropdown, custom_role], | |
| outputs=region_dropdown, | |
| ) | |
| custom_role.change( | |
| fn=gradio_refresh_regions, | |
| inputs=[specialty_dropdown, custom_role], | |
| outputs=region_dropdown, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |