"""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