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 # ======================= @dataclass 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 # ======================= @tool 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) @tool 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()