because I got bored of customising my CV for every job
at main 152 lines 5.4 kB view raw
1import { 2 CVParserService as CVParser, 3 type ExistingUserContext, 4 type ParsedCVData, 5} from "@cv/ai-parser"; 6import type { User } from "@cv/auth"; 7import { PrismaService } from "@cv/system"; 8import { TextExtractorRegistry, TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload"; 9import { Inject, Injectable, Logger } from "@nestjs/common"; 10import { EducationService } from "@/modules/education/education.service"; 11import { AIProviderResolverService } from "@/modules/cv-parser/ai-provider-resolver.service"; 12import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service"; 13import { ProfileService } from "@/modules/profile/profile.service"; 14import type { DataImportSource } from "../data-import-source.interface"; 15 16/** 17 * Build ExistingUserContext from domain models for AI prompt enrichment. 18 */ 19const buildExistingUserContext = ( 20 profile: { fullName?: string | null; headline?: string | null; city?: string | null; country?: string | null } | null, 21 jobs: Array<{ company: { name: string }; role: { name: string }; startDate: Date; endDate?: Date }>, 22 educations: Array<{ institution: { name: string }; degree: string; startDate: Date; endDate: Date | null }>, 23): ExistingUserContext | undefined => { 24 const context: ExistingUserContext = {}; 25 26 if (profile?.fullName) context.name = profile.fullName; 27 if (profile?.headline) context.headline = profile.headline; 28 if (profile?.city) context.city = profile.city; 29 if (profile?.country) context.country = profile.country; 30 31 if (jobs.length > 0) { 32 context.jobs = jobs.map((j) => { 33 const entry: { company: string; role: string; startDate: string; endDate?: string } = { 34 company: j.company.name, 35 role: j.role.name, 36 startDate: j.startDate.toISOString().slice(0, 10), 37 }; 38 if (j.endDate) entry.endDate = j.endDate.toISOString().slice(0, 10); 39 return entry; 40 }); 41 } 42 43 if (educations.length > 0) { 44 context.education = educations.map((e) => { 45 const entry: { institution: string; degree: string; startDate: string; endDate?: string } = { 46 institution: e.institution.name, 47 degree: e.degree, 48 startDate: e.startDate.toISOString().slice(0, 10), 49 }; 50 if (e.endDate) entry.endDate = e.endDate.toISOString().slice(0, 10); 51 return entry; 52 }); 53 } 54 55 const hasData = Object.keys(context).length > 0; 56 return hasData ? context : undefined; 57}; 58 59/** 60 * Wraps the existing file extraction + AI parsing pipeline 61 * as a DataImportSource. 62 */ 63@Injectable() 64export class FileImportSource implements DataImportSource { 65 readonly name = "file-upload"; 66 private readonly logger = new Logger(FileImportSource.name); 67 68 constructor( 69 @Inject(TEXT_EXTRACTOR_REGISTRY) 70 private readonly textExtractorRegistry: TextExtractorRegistry, 71 private readonly providerResolver: AIProviderResolverService, 72 private readonly profileService: ProfileService, 73 private readonly jobExperienceService: UserJobExperienceService, 74 private readonly educationService: EducationService, 75 private readonly prisma: PrismaService, 76 ) {} 77 78 async execute( 79 user: User, 80 params: Record<string, unknown>, 81 onStatus: (message: string) => Promise<void>, 82 ): Promise<ParsedCVData> { 83 const buffer = params["buffer"] as Buffer; 84 const mimeType = params["mimeType"] as string; 85 86 await onStatus("Extracting text from file"); 87 88 const extraction = await this.textExtractorRegistry.extract( 89 buffer, 90 mimeType, 91 ); 92 93 if (!extraction.success) { 94 throw new Error(`Text extraction failed: ${extraction.error}`); 95 } 96 97 if (!extraction.text || extraction.text.trim().length === 0) { 98 throw new Error("Could not extract any text from the file"); 99 } 100 101 this.logger.log(`Extracted ${extraction.text.length} chars from ${mimeType}`); 102 this.logger.debug(`Extracted text preview: ${extraction.text.slice(0, 500)}`); 103 104 await onStatus("Gathering existing profile data"); 105 106 const existingContext = await this.gatherUserContext(user); 107 108 await onStatus("Analyzing with AI"); 109 110 const provider = await this.providerResolver.resolveForUser(user); 111 this.logger.log(`AI provider: ${provider.constructor.name}`); 112 113 const parser = new CVParser(provider); 114 const result = await parser.parseCVText(extraction.text, existingContext); 115 116 return result; 117 } 118 119 private async gatherUserContext(user: User): Promise<ExistingUserContext | undefined> { 120 try { 121 const firstProfile = await this.prisma.profile.findFirst({ 122 where: { userId: user.id }, 123 select: { id: true }, 124 }); 125 126 if (!firstProfile) return undefined; 127 128 const [profile, jobs, educationResult] = await Promise.all([ 129 this.profileService.findByIdOrFail(firstProfile.id), 130 this.jobExperienceService.findForProfile(firstProfile.id), 131 this.educationService.findManyForProfile(firstProfile.id), 132 ]); 133 134 const context = buildExistingUserContext( 135 profile, 136 jobs, 137 educationResult.edges.map((e) => e.node), 138 ); 139 140 if (context) { 141 this.logger.debug(`User context: ${JSON.stringify(context)}`); 142 } 143 144 return context; 145 } catch (err) { 146 this.logger.warn( 147 `Failed to gather user context (non-fatal): ${err instanceof Error ? err.message : err}`, 148 ); 149 return undefined; 150 } 151 } 152}