Загрузка данных


import os
import json
import time
import requests
from dotenv import load_dotenv

import vk_api
from vk_api.longpoll import VkLongPoll, VkEventType
from huggingface_hub import InferenceClient

load_dotenv()

# ---------- ENV ----------
VK_TOKEN = os.getenv("VK_BOT_TOKEN")
BACKEND_BASE_URL = os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:5000")

HF_TOKEN = os.getenv("HF_TOKEN")
HF_MODEL = os.getenv("HF_MODEL", "Qwen/Qwen3-4B-Instruct-2507")

print("DEBUG BACKEND_BASE_URL =", BACKEND_BASE_URL)

if not VK_TOKEN:
    raise RuntimeError("VK_BOT_TOKEN is missing in .env")
if not HF_TOKEN:
    raise RuntimeError("HF_TOKEN is missing in .env")


# ---------- HF CLIENT ----------
hf_client = InferenceClient(api_key=HF_TOKEN)


def hf_chat(messages, temperature=0.2, max_tokens=140):
    """
    HuggingFace Inference Providers: OpenAI-like chat.completions.create
    ВАЖНО: тут НЕТ timeout в сигнатуре — не передаем его.
    """
    completion = hf_client.chat.completions.create(
        model=HF_MODEL,
        messages=messages,
        temperature=temperature,
        max_tokens=max_tokens,
    )
    return completion.choices[0].message.content
# ---------- BACKEND API ----------
def get_context_from_backend(vk_user_id):
    response = requests.get(
        f"{BACKEND_BASE_URL}/api/interview/context",
        params={"vk_user_id": str(vk_user_id)},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def start_interview_in_backend(candidate_id, vacancy_id):
    response = requests.post(
        f"{BACKEND_BASE_URL}/api/interview/start",
        json={
            "candidate_id": candidate_id,
            "vacancy_id": vacancy_id,
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def save_answer_to_backend(session_id, question_number, question_text, answer_text):
    response = requests.post(
        f"{BACKEND_BASE_URL}/api/interview/answer",
        json={
            "session_id": session_id,
            "question_number": question_number,
            "question_text": question_text,
            "answer_text": answer_text,
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def complete_interview_in_backend(session_id, report):
    response = requests.post(
        f"{BACKEND_BASE_URL}/api/interview/complete",
        json={
            "session_id": session_id,
            "report": report,
            "score": None,
            "llm_provider": "huggingface",
            "model": HF_MODEL,
        },
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


# ---------- LLM LOGIC ----------
def generate_question(vacancy_text: str, resume_text: str, answers: dict, step: int) -> str:
    step_name = INTERVIEW_STEPS[step]

    asked_questions = "\n".join([f"- {q}" for q in answers.keys()]) if answers else "- пока нет"

    system = """
Ты — профессиональный HR-специалист уровня Senior Talent Acquisition Manager с опытом проведения тысяч интервью.

Твоя задача — провести первичный скрининг кандидата через чат, выявить:
* соответствие требованиям вакансии
* реальные компетенции (hard + soft skills)
* мотивацию и ценности
* cultural fit
* адекватность самооценки и честность

Ты НЕ просто задаешь вопросы — ты проводишь структурированное интервью по лучшим практикам HR.

Важно: интервью проходит в чате, поэтому вопросы должны быть такими, чтобы кандидат мог ответить письменно и без длинного рассказа. Предпочитай вопросы, на которые удобно ответить в 1–4 коротких абзацах или нескольких предложениях. Если нужна глубина, раскрывай тему через уточняющие follow-up вопросы, а не через один слишком широкий вопрос.

========================================
ОБЩИЕ ПРИНЦИПЫ
========================================

1. Задавай только открытые вопросы, но формулируй их компактно и понятно для письменного ответа в чате.
2. Используй метод STAR / STARR:
   * Situation (ситуация)
   * Task (задача)
   * Action (действия)
   * Result (результат)
   * Reflection (что понял / изменил)
3. Не проси кандидата сразу рассказывать длинную историю. Разбивай тему на короткие последовательные вопросы.
4. Всегда углубляй ответы:
   * задавай уточняющие вопросы
   * проси конкретику (цифры, действия, вклад)
5. Избегай шаблонных вопросов (типа "ваши сильные стороны").
6. Чередуй типы вопросов:
   * поведенческие
   * ситуационные
   * проективные
   * технические
7. Создавай безопасную атмосферу:
   * дружелюбный тон
   * без давления
8. Не задавай сразу много вопросов — один вопрос за раз.
9. После каждого ответа:
   * оцени глубину
   * если ответ поверхностный — углубляй
10. Формулируй вопросы так, чтобы кандидат понимал: не нужен идеальный ответ, важны конкретика и честность.

========================================
ПРИНЦИПЫ ВОПРОСОВ ДЛЯ ЧАТА
========================================

1. Один вопрос = одна мысль.
2. Вопрос должен быть коротким, ясным и удобным для письменного ответа.
3. Избегай формулировок вроде:
   * "Подробно расскажите всю историю..."
   * "Опишите весь ваш опыт..."
4. Предпочитай формулировки вроде:
   * "Какой опыт из вашего прошлого наиболее близок к этой роли?"
   * "Какую задачу вы решали лично?"
   * "Что было самым сложным?"
   * "Почему выбрали именно это решение?"
5. Если нужен кейс, собирай его по частям:
   * сначала ситуация
   * потом действия
   * потом результат
   * потом выводы
6. Если ответ уже достаточен — не перегружай дополнительными вопросами.
7. Если кандидат отвечает кратко, но по делу — это допустимо. Не требуй лишней детализации без необходимости.

========================================
СТРУКТУРА ИНТЕРВЬЮ
========================================

Этап 1: Мотивация
Цель: понять интерес кандидата к вакансии и ожидания от работы.
Примеры:
* Почему вам интересна эта вакансия?
* Чем бы вы хотели заниматься в этой роли?
* Что для вас важно в будущей работе?
* Почему сейчас рассматриваете предложения?

Этап 2: Проверка навыков из резюме
Цель: подтвердить актуальность навыков и уточнить реальный уровень владения инструментами.
Примеры:
* Какие из навыков в резюме вы сейчас используете чаще всего?
* Какие навыки из резюме вы бы добавили или убрали?
* С каким инструментом из резюме у вас был самый практический опыт?
* Какую задачу вы решали с использованием этого инструмента?

Этап 3: Проектный опыт
Цель: проверить реальные кейсы и вклад кандидата.
Примеры:
* Какой проект из вашего опыта ближе всего к этой вакансии?
* Какую задачу в этом проекте выполняли именно вы?
* Что было самым сложным?
* Какой результат получился?

Этап 4: Команда / конфликт / дедлайны
Цель: оценить коммуникацию, поведение в сложных ситуациях и работу в сроках.
Примеры:
* С кем вы чаще всего взаимодействовали в проектах?
* Был ли рабочий конфликт или расхождение во мнениях?
* Как вы действовали в ситуации коротких сроков?
* Как расставляли приоритеты?

Этап 5: Организационные условия
Цель: уточнить формат работы, ожидания и ограничения.
Примеры:
* Какой формат работы вы готовы рассматривать?
* Какие зарплатные ожидания для вас актуальны?
* Когда вы готовы выйти на работу?
* Есть ли условия, которые для вас принципиальны?


========================================
ЛОГИКА ПОВЕДЕНИЯ
========================================

* Если кандидат отвечает общо — проси один конкретный пример
* Если кандидат говорит "мы" — уточни "а что именно делали вы?"
* Если кандидат отвечает слишком длинно — мягко фокусируй: "если коротко, что было вашим главным вкладом?"
* Если кандидат идеален — спроси про ошибки, сложности или что бы он сделал иначе
* Если кандидат уходит от ответа — мягко возвращай к сути
* Если кандидат пишет кратко, но по делу — принимай такой формат и двигайся дальше
* Не требуй длинных историй: глубину собирай серией коротких вопросов

========================================
СТИЛЬ ОБЩЕНИЯ
========================================

* дружелюбный, но профессиональный
* краткие и понятные вопросы
* без HR-клише
* как живой диалог, не анкета
* вопросы должны быть удобны для ответа в чате
* не проси "полный рассказ", если можно получить нужное через 2–3 коротких уточнения

Твоя задача сейчас — задать ОДИН новый вопрос кандидату строго в рамках текущего этапа интервью.
Нельзя повторять уже заданные вопросы.
Вопрос должен быть коротким, естественным, конкретным и удобным для ответа в чате.
Верни только текст вопроса, без пояснений, без нумерации, без префиксов.
""".strip()

    user = f"""
Вакансия:
{vacancy_text[:2000]}

Резюме:
{resume_text[:2000]}

Текущий этап интервью:
{step_name}

Уже заданные вопросы:
{asked_questions}

Сгенерируй ОДИН новый вопрос, который:
1. относится к текущему этапу
2. не повторяет предыдущие вопросы
3. проверяет реальный опыт, подход или мотивацию кандидата
4. удобен для короткого письменного ответа в чате
5. не требует длинного рассказа
""".strip()

    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": user},
    ]

    q = hf_chat(messages, temperature=0.3, max_tokens=90).strip()
    return q


def score_candidate(vacancy_text: str, resume_text: str, answers: dict) -> str:
    system = """
Ты senior HR-рекрутер и hiring analyst.

Твоя задача — оценить кандидата по результатам первичного AI-интервью.

Оцени кандидата по 7 блокам:
1. Мотивация
2. Навыки
3. Проектный опыт
4. Команда / конфликт / дедлайны
5. Организационные условия
6. Соответствие вакансии
7. Итоговая рекомендация

Используй шкалу от 1 до 10.

Верни ответ СТРОГО в формате:

Мотивация: X/10
Навыки: X/10
Проектный опыт: X/10
Команда/конфликт/дедлайны: X/10
Организационные условия: X/10
Соответствие вакансии: X/10
Итог: X/10
Рекомендация: рекомендовать / резерв / не рекомендовать
Вывод: 1-2 коротких предложения.

Критерии:
- Мотивация = интерес к вакансии, осознанность карьерных ожиданий
- Навыки = соответствие навыков из резюме требованиям вакансии
- Проектный опыт = наличие релевантных кейсов и реального вклада
- Команда/конфликт/дедлайны = зрелость коммуникации, приоритизация, поведение в сложных ситуациях
- Организационные условия = соответствие формата, ожиданий и условий вакансии
- Соответствие вакансии = общий fit кандидата под позицию

Пиши кратко. Не добавляй лишние разделы.
""".strip()

    user = f"""
Вакансия:
{vacancy_text[:4000]}

Резюме:
{resume_text[:4000]}

Ответы кандидата (JSON):
{json.dumps(answers, ensure_ascii=False)[:9000]}

Сформируй итоговую оценку кандидата.
""".strip()

    messages = [
        {"role": "system", "content": system},
        {"role": "user", "content": user},
    ]

    return hf_chat(messages, temperature=0.2, max_tokens=600).strip()


# ---------- VK BOT ----------
vk_session = vk_api.VkApi(token=VK_TOKEN)
vk = vk_session.get_api()
longpoll = VkLongPoll(vk_session)

INTERVIEW_STEPS = [
    "Мотивация"
    "Проверка навыков из резюме"
    "Релевантность имеющегося опыта",
    "Взаимодействие с командой",
    "Опыт работы в конфликтных условиях",
    "Отношение к коротким дедлайнам",
    "Обоснование принятых решений"
    "Организационные условия",
]
MAX_Q = len(INTERVIEW_STEPS)

# user_states[user_id] = {
#     "step": 0,
#     "answers": {},
#     "last_q": "",
#     "session_id": None,
#     "candidate_id": None,
#     "vacancy_id": None,
#     "vacancy_text": "",
#     "resume_text": "",
# }
user_states = {}


def send_message(user_id, text):
    vk.messages.send(user_id=user_id, message=text, random_id=0)


def ask_next_question(user_id, state):
    q = generate_question(
        vacancy_text=state["vacancy_text"],
        resume_text=state["resume_text"],
        answers=state["answers"],
        step=state["step"],
    )
    state["last_q"] = q
    send_message(user_id, q)


print("VK bot started")
print(f"HF model: {HF_MODEL}")
print(f"Backend URL: {BACKEND_BASE_URL}")


for event in longpoll.listen():
    if event.type != VkEventType.MESSAGE_NEW or not event.to_me:
        continue

    user_id = event.user_id
    text = (event.text or "").strip()

    # команды
    if text.lower() in ["/start", "start", "начать"]:
        try:
            context = get_context_from_backend(user_id)
            
            candidate_id = context["candidate"]["id"]
            vacancy_id = context["vacancy"]["id"]
            
            vacancy_text = (
                f"Название вакансии: {context['vacancy']['title']}\n"
                f"Компания/отдел: {context['vacancy'].get('department', '')}\n\n"
                f"Описание:\n{context['vacancy'].get('description', '')}\n\n"
                f"Требования:\n{context['vacancy'].get('requirements', '')}"
            )

            resume_text = context["resume"]["raw_text"]

            session_data = start_interview_in_backend(candidate_id, vacancy_id)

            user_states[user_id] = {
                "step": 0,
                "answers": {},
                "last_q": "",
                "session_id": session_data["session_id"],
                "candidate_id": candidate_id,
                "vacancy_id": vacancy_id,
                "vacancy_text": vacancy_text,
                "resume_text": resume_text,
            }

            send_message(
                user_id,
                (
                    f"Здравствуйте! Я ИИ-рекрутер. Рассматриваем вашу кандидатуру на позицию "
                    f"«{context['vacancy']['title']}».\n\n"
                    "Я задам несколько вопросов о мотивации, навыках, проектном опыте, "
                    "командной работе и организационных ожиданиях.\n\n"
                    "Пожалуйста, отвечайте на каждый вопрос одним сообщением. "
                    "У вас сейчас есть возможность пройти короткое интервью?"
                )
            )

            ask_next_question(user_id, user_states[user_id])

        except Exception as e:
            send_message(user_id, f"Не удалось начать интервью: {e}")

        continue



    if text.lower() in ["/stop", "stop", "стоп", "выход"]:
        user_states.pop(user_id, None)
        send_message(user_id, "Интервью остановлено.")
        continue

    # если интервью не начато
    if user_id not in user_states:
        send_message(user_id, "Напишите /start чтобы начать интервью.")
        continue

    state = user_states[user_id]
    
    if state.get("status") == "waiting_consent":
        if text.lower() in ["да", "готов", "готова", "можно", "начать", "ок", "yes"]:
            state["status"] = "in_progress"
            send_message(user_id, "Отлично, начинаем. Первый вопрос.")
            ask_next_question(user_id, state)
        else:
            send_message(
                user_id,
                "Хорошо. Когда будете готовы, напишите «да», и мы начнем интервью."
        )
        continue


    # сохраняем ответ на текущий шаг
    step_idx = state["step"]
    if step_idx < MAX_Q:
        last_question = state.get("last_q", f"question_{step_idx + 1}")
        state["answers"][last_question] = text
        try:

            print("SENDING ANSWER:", text)

            save_answer_to_backend(
                session_id=state["session_id"],
                question_number=step_idx + 1,
                question_text=last_question,
                answer_text=text,
            )

            print("ANSWER SAVED")

        except Exception as e:
            print("Backend save answer error:", e)
        state["step"] += 1

    # если вопросы закончились — скорим и отправляем на backend
    if state["step"] >= MAX_Q:
        
        send_message(user_id, "✅ Спасибо! Интервью завершено. Ответы отправлены на обработку.")

        # скоринг для админа
        try:
            report = score_candidate(state["vacancy_text"], state["resume_text"], state["answers"])
        except Exception as e:
            report = f"Scoring unavailable: {e}"
            print("Scoring error:", e)


        try:

            print("SENDING FINAL REPOR")

            complete_interview_in_backend(
                session_id=state["session_id"],
                report=report,
            )

            print("REPORT SAVED")
        except Exception as e:
            print("Backend complete interview error:", e)


        user_states.pop(user_id, None)
    else:
        ask_next_question(user_id, state)