Init project

This commit is contained in:
estromenko 2025-10-19 12:14:21 +03:00
commit 0204093de0
9 changed files with 2034 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.env
*.db

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.13

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# vision-career-backend
Sample `.env`:
```dotenv
DEEPINFRA_API_TOKEN=your-token-here
OPENAI_API_KEY=your-token-here
BOT_TOKEN=your-token-here
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
```
Commands:
```bash
PYTHONPATH=$(pwd) uv run --env-file .env backend/index.py
PYTHONPATH=$(pwd) uv run --env-file .env backend/bot.py
```

49
backend/agent.py Normal file
View File

@ -0,0 +1,49 @@
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import InMemorySaver
from langchain_community.tools import DuckDuckGoSearchRun
from langchain_community.vectorstores import Clickhouse, ClickhouseSettings
from langchain_community.embeddings import DeepInfraEmbeddings
from datetime import datetime
from redis.asyncio import Redis
SYSTEM_PROMPT = f"""
Сегодня {datetime.today().isoformat()}.
Ты карьерный копилот для ИТ.
Ответ всегда должен быть, не допускай пустых ответов.
Требования к ответам:
- Пиши кратко (до 56 строк, буллеты приветствуются).
- Всегда проверяй факты: бери данные о вакансиях только из контекста и ссылок в web-поиске.
- В ответ всегда давай источники (минимум 1, лучше 23): ссылка на публичный
- Вакансии можно брать только из контекста, либо обращаться к web-поиску.
- Всегда указывай дату вакансии и ссылку на нее.
- Если вакансия из контекста, то сформируй ссылку на telegram-сообщение, где была указана эта вакансия.
пост/новость/страницу компании. Без «уверенностей».
- Персональные/идентифицирующие данные из непубличных источников не
раскрывай. Если цитируешь, то только из публичных каналов/страниц с ссылкой.
- Если данных недостаточно: честно скажи «не хватает надёжных источников»,
предложи расширить период/переформулировать, либо выполнить веб-поиск.
- После полезного ответа предложи один мягкий следующий шаг.
Отвечай простым текстом. Не используй HTML, Markdown, звёздочки, подчёркивания. Просто текст, но используй emoji.
"""
redis = Redis()
llm = ChatOpenAI(model_name="zai-org/GLM-4.6", openai_api_base="https://api.deepinfra.com/v1/openai")
embedding = DeepInfraEmbeddings(model_id="Qwen/Qwen3-Embedding-8B")
vectorstore = Clickhouse(embedding, ClickhouseSettings(port=8123, username="default", password="", index_type="vector_similarity"))
search_tool = DuckDuckGoSearchRun()
async def get_relevant_vacancies(requirements: str):
"""Получает релевантные вакансии из базы данных по переданным требованиям."""
return await vectorstore.asimilarity_search(requirements, k=10)
async def get_user_resume(user_id: int):
"""Получает резюме пользователя для подбора вакансий."""
return await redis.get(user_id)
agent = create_agent(
model=llm,
tools=[search_tool, get_relevant_vacancies, get_user_resume],
system_prompt=SYSTEM_PROMPT,
checkpointer=InMemorySaver(),
)

72
backend/bot.py Normal file
View File

@ -0,0 +1,72 @@
import os
import io
import traceback
from telegram import Update, ReplyKeyboardMarkup
from telegram.ext import filters, ApplicationBuilder, MessageHandler, CommandHandler, ContextTypes
from backend.agent import agent, redis
from pypdf import PdfReader
VACANCIES = "👥 Вакансии"
RESUME = "📄 Резюме"
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
text = "Привет! Я карьерный копилот: помогу с работой, интервью и расскажу новости по рынку, специально для тебя. С чего начнем?"
reply_markup = ReplyKeyboardMarkup([[VACANCIES, RESUME]], resize_keyboard=True, one_time_keyboard=False)
await context.bot.send_message(chat_id=update.effective_chat.id, text=text, reply_markup=reply_markup)
async def prompt(update: Update, context: ContextTypes.DEFAULT_TYPE):
if update.message.text == RESUME:
resume = await redis.get(update.effective_user.id)
text = "Пришли мне файл с твоим резюме, чтобы я смог найти подходящие вакансии на основе твоих компетенций!"
if resume:
text = "Я уже получил твое резюме. Если хочешь его обновить, пришли мне новый файл!"
await context.bot.send_message(update.effective_chat.id, text)
return
user_prompt = update.message.text
if update.message.text == VACANCIES:
user_prompt = "Пришли мне актуальные вакансии на основе моего резюме"
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{user_prompt}"}]},
config={"configurable": {"thread_id": "1"}},
)
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:
print(traceback.format_exception(context.error)[-1])
await context.bot.send_message(chat_id=update.effective_chat.id, text="Произошла ошибка. Повтоите попытку позже.")
async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE):
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)
await redis.set(update.effective_user.id, resume)
await context.bot.send_message(chat_id=update.effective_chat.id, text="Отлично! Запомнил Ваше резюме.")
def main():
application = ApplicationBuilder().token(os.environ["BOT_TOKEN"]).build()
start_handler = CommandHandler('start', start)
application.add_handler(start_handler)
application.add_handler(MessageHandler(filters.TEXT & (~filters.COMMAND), prompt))
application.add_handler(MessageHandler((filters.Document.ALL | filters.PHOTO) & (~filters.COMMAND), handle_document))
application.add_error_handler(error_handler)
application.run_polling()
if __name__ == '__main__':
main()

27
backend/index.py Normal file
View File

@ -0,0 +1,27 @@
from backend.agent import vectorstore
from langchain_core.documents import Document
import clickhouse_connect
query = """
SELECT id, chat_id, telegram_id, message, timestamp
FROM telegram_parser_chatmessage
WHERE timestamp >= now() - INTERVAL 30 DAY
AND length(message) > 150
AND arrayCount(x -> position(message, x) > 0, [
'вакансия', 'ищем', 'требуется', 'разработчик', 'будет плюсом',
'зарплата', 'оклад', 'з/п', 'руб', 'опыт работы',
'требования', 'обязанности', 'условия', 'компания', 'офис',
'удаленно', 'гибкий график', 'полный день', 'частичная занятость',
'резюме', 'собеседование', 'junior', 'middle', 'senior'
]) >= 5
"""
client = clickhouse_connect.create_client(port=18123)
documents = []
for row in client.query(query).result_rows:
(id, chat_id, telegram_id, message, timestamp) = row
metadata = {"chat_id": chat_id, "telegram_id": telegram_id, "timestamp": timestamp.isoformat()}
documents.append(Document(id=id, page_content=message, metadata=metadata))
vectorstore.add_documents(documents)

20
compose.yaml Normal file
View File

@ -0,0 +1,20 @@
services:
clickhouse:
image: clickhouse:25.7.5.34-jammy
restart: always
environment:
CLICKHOUSE_PASSWORD: ""
CLICKHOUSE_DB: default
CLICKHOUSE_USER: default
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
ports:
- "127.0.0.1:8123:8123"
- "127.0.0.1:9000:9000"
volumes:
- /srv/vision-career/clickhouse/ch_data:/var/lib/clickhouse/
- /srv/vision-career/clickhouse/ch_logs:/var/log/clickhouse-server/
valkey:
image: valkey/valkey:latest
restart: always
ports:
- "127.0.0.1:6379:6379"

20
pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
[project]
name = "vision-career-backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"clickhouse-connect>=0.9.2",
"ddgs>=9.6.1",
"deepinfra>=0.1.0",
"langchain>=0.3.27",
"langchain-community>=0.3.31",
"langchain-openai>=0.3.35",
"pypdf>=6.1.2",
"python-telegram-bot>=22.5",
"redis>=6.4.0",
]
[tool.uv.sources]
langchain-community = { git = "https://github.com/estromenko/langchain-community", subdirectory = "libs/community", rev = "feat/add-vector-similarity-index-support" }

1814
uv.lock Normal file

File diff suppressed because it is too large Load Diff