/
range
/
stock
Обзор
Документация
Войти
/
range
/
stock
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
backend/src/intelligence/intelligence.service.ts
314 строк
9 KB
Range18
Shedevro commit
23 ноя 2025, 09:15
23 ноя 2025, 09:15
59bb6f3
Код
Авторство
О чём код?
import { Injectable, NotFoundException } from '@nestjs/common'; import { Specialty } from '../specialties/entities/specialty.entity'; import { Course, CourseDifficulty, CourseType, } from '../courses/entities/course.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { SpecialtyCourse } from '../specialties/entities/specialty-course.entity'; import { CheckCourseDto } from './dto/check-course.dto'; import { User } from '../users/entities/user.entity'; import { UserCourse } from '../users/entities/user-course.entity'; import { CourseAdvice } from './types/IcourseAdvice'; @Injectable() export class IntelligenceService { constructor( @InjectRepository(Course) private readonly coursesRepository: Repository<Course>, @InjectRepository(SpecialtyCourse) private readonly specialtyCourseRepository: Repository<SpecialtyCourse>, @InjectRepository(Specialty) private readonly specialtiesRepository: Repository<Specialty>, @InjectRepository(UserCourse) private readonly userCoursesRepo: Repository<UserCourse>, @InjectRepository(User) private readonly usersRepo: Repository<User>, ) {} THRESHOLD = 0.7; // порог "хорошего" покрытия по навыку ALPHA = 0.7; // вес качества vs количества COVERAGE_EDGE = 0.4; COVERAGE_EDGE_MIN = 0.15; async checkCourseForSpecialty(dto: CheckCourseDto): Promise<{ coverage: number; isSuitable: boolean; }> { const [specialty, course] = await Promise.all([ this.specialtiesRepository.findOne({ where: { id: dto.specialtyId }, relations: ['requiredSkills', 'requiredSkills.skill'], }), this.coursesRepository.findOne({ where: { id: dto.courseId }, relations: ['skills', 'skills.skill'], }), ]); if (!specialty) { throw new NotFoundException('Specialty not found'); } if (!course) { throw new NotFoundException('Course not found'); } const coverage = this.getCourseCoverage(specialty, course); const isSuitable = coverage >= this.COVERAGE_EDGE_MIN; return { coverage, isSuitable }; } /** * Оценка покрытия курса для специальности в [0, 1] * Учитывает: * - долю требуемых навыков, которые курс покрывает хотя бы на threshold * - среднее качество покрытия всех требуемых навыков */ getCourseCoverage(specialty: Specialty, course: Course): number { const requiredSkills = specialty.requiredSkills ?? []; const courseSkills = course.skills ?? []; if (!requiredSkills.length) { return 0; } let requiredCount = 0; let coveredCount = 0; let sumCoverage = 0; for (const rs of requiredSkills) { if (!rs.skill) continue; const requiredLevel = rs.level ?? 0; if (requiredLevel <= 0) continue; requiredCount++; const courseSkill = courseSkills.find( (cs) => cs.skill && cs.skill.id === rs.skill.id, ); if (!courseSkill) { continue; } const courseLevel = courseSkill.level ?? 0; const R_score = requiredLevel / 100; const L_score = courseLevel / 100; let cov = 0; if (R_score > 0) { cov = Math.min(L_score, R_score) / R_score; } sumCoverage += cov; if (cov >= this.THRESHOLD) { coveredCount++; } } if (!requiredCount) { return 0; } const countScore = coveredCount / requiredCount; const qualityScore = sumCoverage / requiredCount; const score = this.ALPHA * qualityScore + (1 - this.ALPHA) * countScore; return score; } async recommendCoursesForSpecialty(specialty: Specialty): Promise<Course[]> { const requiredSkillsIds = specialty.requiredSkills.map((rs) => rs.skill.id); const courses = await this.coursesRepository.find({ where: { type: CourseType.CHOICE, skills: { skill: { id: In(requiredSkillsIds) } }, }, relations: ['skills', 'skills.skill'], }); // console.log(specialty.requiredSkills); const coursesWithScore = courses .map((course) => ({ course, coverage: this.getCourseCoverage(specialty, course), })) .sort((a, b) => b.coverage - a.coverage) .filter((x) => x.coverage >= this.COVERAGE_EDGE); for (const courseWithScore of coursesWithScore) { await this.specialtyCourseRepository.upsert( { course: { id: courseWithScore.course.id }, specialty: { id: specialty.id }, coverage: Math.floor(courseWithScore.coverage * 100), }, ['course', 'specialty'], ); } return coursesWithScore.map((x) => x.course); } USER_GOOD_THRESHOLD = 0.8; // "всё хорошо" USER_BAD_THRESHOLD = 0.4; // "проседает" getUserCoverageForCourse(user: User, course: Course): number { const userSkills = user.userSkills ?? []; const courseSkills = course.skills ?? []; if (!courseSkills.length) { return 1; // курс не требует навыков — считаем, что ок } let requiredCount = 0; let sumCoverage = 0; for (const cs of courseSkills) { if (!cs.skill) continue; requiredCount++; const userSkill = userSkills.find( (us) => us.skill && us.skill.id === cs.skill.id, ); const userLevel = userSkill?.level ?? 0; const requiredLevel = cs.level ?? 0; const U_score = userLevel / 100; const R_score = requiredLevel / 100; let cov = 0; if (R_score > 0) { cov = Math.min(U_score, R_score) / R_score; } sumCoverage += cov; } if (!requiredCount) { return 1; } return sumCoverage / requiredCount; } private difficultyRank(diff?: CourseDifficulty | null): number { switch (diff) { case CourseDifficulty.EASY: return 1; case CourseDifficulty.MEDIUM: return 2; case CourseDifficulty.HARD: return 3; default: return 0; } } async adviceDowngradeStudy(userId: number): Promise<CourseAdvice[]> { // подгружаем пользователя с навыками const fullUser = await this.usersRepo.findOne({ where: { id: userId }, relations: ['userSkills', 'userSkills.skill'], }); if (!fullUser) { throw new NotFoundException('User not found'); } // подгружаем курсы пользователя const userCourses = await this.userCoursesRepo.find({ where: { user: { id: userId } }, relations: ['course', 'course.skills', 'course.skills.skill'], }); const advices: CourseAdvice[] = []; for (const uc of userCourses) { const course = uc.course; const coverage = this.getUserCoverageForCourse(fullUser, course); const currentDiff = uc.difficultyLevel; const courseLevels = course.difficultyLevels ?? []; const currentRank = this.difficultyRank(currentDiff); const easierLevels = courseLevels.filter( (lvl) => this.difficultyRank(lvl) < currentRank, ); const harderLevels = courseLevels.filter( (lvl) => this.difficultyRank(lvl) > currentRank, ); // 1) если проседает по текущему уровню if (coverage < this.USER_BAD_THRESHOLD) { let message: string; if (easierLevels.length) { message = 'Уровень навыков пока не дотягивает до выбранной сложности. ' + 'Рекомендуется выбрать более лёгкий уровень курса.'; } else { message = 'Уровень навыков пока не дотягивает до требований курса. ' + 'Рекомендуется снизить нагрузку или подобрать более простой курс.'; } advices.push({ courseId: course.id, courseTitle: course.title, currentDifficulty: currentDiff, coverage, advice: 'DOWNGRADE', message, }); continue; } // 2) если всё хорошо — можно рекомендовать повысить уровень if (coverage >= this.USER_GOOD_THRESHOLD && harderLevels.length) { advices.push({ courseId: course.id, courseTitle: course.title, currentDifficulty: currentDiff, coverage, advice: 'UPGRADE', message: 'Текущий уровень навыков хорошо покрывает требования курса. ' + 'Можно рассмотреть повышение уровня сложности.', }); continue; } // 3) нейтральная ситуация — всё норм, без рекомендаций изменения advices.push({ courseId: course.id, courseTitle: course.title, currentDifficulty: currentDiff, coverage, advice: 'OK', message: 'Текущий уровень курса подходит вашему уровню.', }); } return advices; } }