Files
freelancer-match/backend/app/services/ai_service.py
T

151 lines
5.0 KiB
Python

"""AI-сервис для матчинга фрилансеров и проектов."""
import logging
from typing import Any
import openai
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.project import Project
from app.models.user import FreelancerProfile
logger = logging.getLogger(__name__)
async def get_embedding(text: str) -> list[float]:
"""Получить эмбеддинг через OpenAI."""
client = openai.OpenAI(api_key=settings.OPENAI_API_KEY)
response = await client.embeddings.create(
model=settings.EMBEDDING_MODEL,
input=text,
)
return response.data[0].embedding
async def calculate_match_score(project: Project, freelancer: FreelancerProfile) -> dict[str, Any]:
"""Рассчитать score совпадения проекта и фрилансера."""
# 1. Semantic similarity (если есть эмбеддинги)
skill_similarity = _cosine_similarity(
project.skill_embeddings or [],
freelancer.skill_embeddings or []
) if project.skill_embeddings and freelancer.skill_embeddings else 0.5
# 2. Опыт
exp_score = min(freelancer.experience_years / max(project.required_skills.__len__() * 3, 3), 1.0) \
if freelancer.experience_years else 0.5
# 3. Рейтинг
rating_weight = (freelancer.rating - 4.0) * 0.5 + 0.5
# 4. Время ответа
response_score = max(0, 1 - (freelancer.response_time_hours or 24) / 24)
# 5. Совпадение навыков
skills_match = len(set(project.required_skills) & set(freelancer.skills)) / \
max(len(project.required_skills), 1) if project.required_skills else 0
# Взвешенная сумма
score = (
skill_similarity * 0.35 +
exp_score * 0.20 +
rating_weight * 0.15 +
response_score * 0.10 +
skills_match * 0.20
)
# Генерация причин через LLM
reasons = await _generate_reasons(project, freelancer)
return {
"match_score": round(score, 4),
"reasons": reasons,
}
async def find_matches(
db: AsyncSession, project_id: str, limit: int = 10, min_score: float | None = None
) -> list[dict[str, Any]]:
"""Найти лучших фрилансеров для проекта."""
# Получаем проект
from app.models.ai_match import AIMatch
# Проверяем кэш в Redis
import redis.asyncio as aioredis
r = aioredis.from_url(settings.REDIS_URL)
cache_key = f"ai_matches:{project_id}"
cached = await r.get(cache_key)
if cached:
return __import__("json").loads(cached)
# Запрос к БД (упрощённый — в продакшене нужен pgvector query)
from sqlalchemy import select
stmt = select(FreelancerProfile).order_by(
FreelancerProfile.rating.desc()
).limit(limit * 2)
result = await db.execute(stmt)
freelancers = result.scalars().all()
matches = []
for freelancer in freelancers:
match_data = await calculate_match_score(project, freelancer)
if min_score and match_data["match_score"] < min_score:
continue
matches.append({
"freelancer_id": str(freelancer.user_id),
"name": freelancer.user.full_name or "Аноним",
"skills_matched": list(set(project.required_skills) & set(freelancer.skills)),
**match_data,
})
matches.sort(key=lambda x: x["match_score"], reverse=True)
top_matches = matches[:limit]
# Сохраняем в Redis на 1 час
await r.setex(cache_key, 3600, __import__("json").dumps(top_matches))
return top_matches
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Косинусное сходство двух векторов."""
if not a or not b:
return 0.5
dot = sum(x * y for x, y in zip(a, b))
norm_a = (sum(x ** 2 for x in a) ** 0.5)
norm_b = (sum(y ** 2 for y in b) ** 0.5)
if norm_a == 0 or norm_b == 0:
return 0.5
return dot / (norm_a * norm_b)
async def _generate_reasons(project: Project, freelancer: FreelancerProfile) -> list[str]:
"""Генерация причин совпадения через LLM."""
reasons = []
if freelancer.experience_years and freelancer.experience_years >= 3:
reasons.append(f"Опыт {freelancer.experience_years} лет в разработке")
matched_skills = set(project.required_skills) & set(freelancer.skills)
if matched_skills:
reasons.append(f"Совпадение навыков: {', '.join(list(matched_skills)[:3])}")
if freelancer.rating >= 4.5:
reasons.append("Высокий рейтинг")
if not reasons:
reasons.append("Профиль соответствует требованиям проекта")
return reasons