vision-career/vacancies/main/bot.py
estromenko d3d4766abb
All checks were successful
release / docker (push) Successful in 23s
Add context from vector search to RAG pipeline
2025-12-02 23:38:08 +03:00

176 lines
7.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import io
import os
import traceback
from typing import Literal
from asgiref.sync import sync_to_async
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from pydantic import BaseModel
from pypdf import PdfReader
from telegram import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
Update,
)
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
from vacancies.conf.settings import DB_URI
from vacancies.main import prompts
from vacancies.main.models import Customer, CustomerCV, JobTitle
from vacancies.main.recommendations import get_next_vacancy
from django.conf import settings
from qdrant_client import AsyncQdrantClient
from openai import AsyncOpenAI
qdrant_client = AsyncQdrantClient(url=settings.QDRANT_URL)
openai_client = AsyncOpenAI(base_url="https://openrouter.ai/api/v1")
async def get_relevant_messages(query: str):
"""Получает релевантные сообщения по запросу пользователя."""
embedding = await openai_client.embeddings.create(
model="qwen/qwen3-embedding-8b",
input=query,
encoding_format="float",
)
response = await qdrant_client.query_points(
collection_name="messages",
query=embedding.data[0].embedding,
limit=20,
)
messages = [hit.payload["message"] for hit in response.points]
return "\n\n".join(messages)
async def get_user_resume(user_id: int):
"""Получает резюме пользователя для подбора вакансий."""
customer_cv = await CustomerCV.objects.filter(customer__telegram_id=user_id).afirst()
return customer_cv.content if customer_cv else ""
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await Customer.objects.aget_or_create(
telegram_id=update.effective_user.id,
defaults=dict(
username=update.effective_user.username,
chat_id=update.effective_chat.id,
),
)
keyboard = [[KeyboardButton("Получить следующую вакансию")]]
reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=False)
text = "Привет! Я карьерный копилот: помогу с работой, интервью и расскажу новости по рынку, специально для тебя. С чего начнем?"
await context.bot.send_message(chat_id=update.effective_chat.id, text=text, reply_markup=reply_markup)
async def next_vacancy(update: Update, context: ContextTypes.DEFAULT_TYPE):
await context.bot.send_message(update.effective_chat.id, "⏳ Обрабатываю твой запрос. Пожалуйста, подождите...")
customer_cv = await CustomerCV.objects.filter(customer__telegram_id=update.effective_user.id).afirst()
if not customer_cv:
message = "Пришлите мне свое резюме, чтобы я мог подобрать вам вакансии!"
await context.bot.send_message(chat_id=update.effective_chat.id, text=message)
return
vacancy = await asyncio.to_thread(get_next_vacancy, customer_cv)
if not vacancy:
message = "Вакансии закончились, возвращайтесь позже!"
await context.bot.send_message(chat_id=update.effective_chat.id, text=message)
return
await context.bot.send_message(
chat_id=update.effective_chat.id,
parse_mode="Markdown",
text=vacancy.get_formatted_response(),
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("Откликнуться", url=vacancy.link),
]]),
)
async def prompt(update: Update, context: ContextTypes.DEFAULT_TYPE):
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
chat_model = ChatOpenAI(
model_name="openai/gpt-5-mini:online",
openai_api_base="https://openrouter.ai/api/v1",
)
agent = create_agent(
model=chat_model,
tools=[get_user_resume, get_relevant_messages],
system_prompt=prompts.BOT_SYSTEM_PROMPT,
checkpointer=checkpointer,
)
message = await context.bot.send_message(update.effective_chat.id, "⏳ Обрабатываю твой запрос. Пожалуйста, подождите...")
response = await agent.ainvoke(
input={"messages": [{"role": "user", "content": f'user_id = {update.effective_user.id}\n{update.message.text}'}]},
config={"configurable": {"thread_id": update.effective_user.id}},
)
await context.bot.editMessageText(response['messages'][-1].content, update.effective_chat.id, message.id)
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
traceback.print_exception(context.error)
await context.bot.send_message(chat_id=update.effective_chat.id, text="Произошла ошибка. Повтоите попытку позже.")
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
message = await context.bot.send_message(update.effective_chat.id, "⏳ Обрабатываю твой запрос. Пожалуйста, подождите...")
if not update.message.document:
await context.bot.send_message(chat_id=update.effective_chat.id, text="Не удалось прочитать информацию из файла! Попробуйте другой формат.")
return
buffer = io.BytesIO()
file = await update.message.document.get_file()
await file.download_to_memory(buffer)
reader = PdfReader(buffer)
resume = "\n".join(page.extract_text() for page in reader.pages)
db_job_titles = await sync_to_async(list)(JobTitle.objects.values_list('title', flat=True))
job_title_map = await sync_to_async(dict)(JobTitle.objects.values_list('title', 'id'))
class Structure(BaseModel):
job_titles: list[Literal[tuple(db_job_titles)]]
min_salary_rub: int | None
max_salary_rub: int | None
openai_client = ChatOpenAI(model_name="gpt-5-mini", temperature=0, seed=42, top_p=1)
structured_llm = openai_client.with_structured_output(Structure)
prompt = f'{prompts.STRUCTURED_OUTPUT_PROMPT} {resume}'
response = await structured_llm.ainvoke(prompt)
customer = await Customer.objects.aget(telegram_id=update.effective_user.id)
customer_cv, _ = await CustomerCV.objects.aupdate_or_create(customer=customer, defaults=dict(
content=resume,
min_salary_rub=response.min_salary_rub,
max_salary_rub=response.max_salary_rub,
))
await customer_cv.job_titles.aset([job_title_map[job_title] for job_title in response.job_titles])
await context.bot.editMessageText("Отлично! Запомнил Ваше резюме.", update.effective_chat.id, message.id)
application = ApplicationBuilder().token(os.environ["BOT_TOKEN"]).concurrent_updates(True).build()
application.add_handler(CommandHandler('start', start, block=False))
application.add_handler(MessageHandler(filters.Text("Получить следующую вакансию"), next_vacancy, block=False))
application.add_handler(MessageHandler(filters.TEXT & (~filters.COMMAND), prompt, block=False))
application.add_handler(MessageHandler((filters.Document.ALL | filters.PHOTO) & (~filters.COMMAND), handle_document, block=False))
application.add_error_handler(error_handler)