import { CVParserService as CVParser, type ExistingUserContext, type ParsedCVData, } from "@cv/ai-parser"; import type { User } from "@cv/auth"; import { PrismaService } from "@cv/system"; import { TextExtractorRegistry, TEXT_EXTRACTOR_REGISTRY } from "@cv/file-upload"; import { Inject, Injectable, Logger } from "@nestjs/common"; import { EducationService } from "@/modules/education/education.service"; import { AIProviderResolverService } from "@/modules/cv-parser/ai-provider-resolver.service"; import { UserJobExperienceService } from "@/modules/job-experience/employment/user-job-experience.service"; import { ProfileService } from "@/modules/profile/profile.service"; import type { DataImportSource } from "../data-import-source.interface"; /** * Build ExistingUserContext from domain models for AI prompt enrichment. */ const buildExistingUserContext = ( profile: { fullName?: string | null; headline?: string | null; city?: string | null; country?: string | null } | null, jobs: Array<{ company: { name: string }; role: { name: string }; startDate: Date; endDate?: Date }>, educations: Array<{ institution: { name: string }; degree: string; startDate: Date; endDate: Date | null }>, ): ExistingUserContext | undefined => { const context: ExistingUserContext = {}; if (profile?.fullName) context.name = profile.fullName; if (profile?.headline) context.headline = profile.headline; if (profile?.city) context.city = profile.city; if (profile?.country) context.country = profile.country; if (jobs.length > 0) { context.jobs = jobs.map((j) => { const entry: { company: string; role: string; startDate: string; endDate?: string } = { company: j.company.name, role: j.role.name, startDate: j.startDate.toISOString().slice(0, 10), }; if (j.endDate) entry.endDate = j.endDate.toISOString().slice(0, 10); return entry; }); } if (educations.length > 0) { context.education = educations.map((e) => { const entry: { institution: string; degree: string; startDate: string; endDate?: string } = { institution: e.institution.name, degree: e.degree, startDate: e.startDate.toISOString().slice(0, 10), }; if (e.endDate) entry.endDate = e.endDate.toISOString().slice(0, 10); return entry; }); } const hasData = Object.keys(context).length > 0; return hasData ? context : undefined; }; /** * Wraps the existing file extraction + AI parsing pipeline * as a DataImportSource. */ @Injectable() export class FileImportSource implements DataImportSource { readonly name = "file-upload"; private readonly logger = new Logger(FileImportSource.name); constructor( @Inject(TEXT_EXTRACTOR_REGISTRY) private readonly textExtractorRegistry: TextExtractorRegistry, private readonly providerResolver: AIProviderResolverService, private readonly profileService: ProfileService, private readonly jobExperienceService: UserJobExperienceService, private readonly educationService: EducationService, private readonly prisma: PrismaService, ) {} async execute( user: User, params: Record, onStatus: (message: string) => Promise, ): Promise { const buffer = params["buffer"] as Buffer; const mimeType = params["mimeType"] as string; await onStatus("Extracting text from file"); const extraction = await this.textExtractorRegistry.extract( buffer, mimeType, ); if (!extraction.success) { throw new Error(`Text extraction failed: ${extraction.error}`); } if (!extraction.text || extraction.text.trim().length === 0) { throw new Error("Could not extract any text from the file"); } this.logger.log(`Extracted ${extraction.text.length} chars from ${mimeType}`); this.logger.debug(`Extracted text preview: ${extraction.text.slice(0, 500)}`); await onStatus("Gathering existing profile data"); const existingContext = await this.gatherUserContext(user); await onStatus("Analyzing with AI"); const provider = await this.providerResolver.resolveForUser(user); this.logger.log(`AI provider: ${provider.constructor.name}`); const parser = new CVParser(provider); const result = await parser.parseCVText(extraction.text, existingContext); return result; } private async gatherUserContext(user: User): Promise { try { const firstProfile = await this.prisma.profile.findFirst({ where: { userId: user.id }, select: { id: true }, }); if (!firstProfile) return undefined; const [profile, jobs, educationResult] = await Promise.all([ this.profileService.findByIdOrFail(firstProfile.id), this.jobExperienceService.findForProfile(firstProfile.id), this.educationService.findManyForProfile(firstProfile.id), ]); const context = buildExistingUserContext( profile, jobs, educationResult.edges.map((e) => e.node), ); if (context) { this.logger.debug(`User context: ${JSON.stringify(context)}`); } return context; } catch (err) { this.logger.warn( `Failed to gather user context (non-fatal): ${err instanceof Error ? err.message : err}`, ); return undefined; } } }