eternalGenius's picture
Update app.py
e4a6a11 verified
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()