because I got bored of customising my CV for every job
at main 178 lines 5.1 kB view raw
1import { CVParserService as CVParser, type ParsedCVData } from "@cv/ai-parser"; 2import type { User } from "@cv/auth"; 3import { 4 TEXT_EXTRACTOR_REGISTRY, 5 type TextExtractorRegistry, 6 validateFile, 7} from "@cv/file-upload"; 8import { Inject, Injectable } from "@nestjs/common"; 9import { AIProviderResolverService } from "./ai-provider-resolver.service"; 10import { 11 EntityResolverService, 12 type ResolvedEducation, 13 type ResolvedJobExperience, 14} from "./entity-resolver.service"; 15 16/** 17 * Parsed CV data with resolved entities 18 */ 19export interface ParsedCVDataWithResolution { 20 jobExperiences: ResolvedJobExperience[]; 21 education: ResolvedEducation[]; 22} 23 24@Injectable() 25export class CVParserService { 26 constructor( 27 @Inject(TEXT_EXTRACTOR_REGISTRY) 28 private readonly textExtractorRegistry: TextExtractorRegistry, 29 private readonly entityResolver: EntityResolverService, 30 private readonly providerResolver: AIProviderResolverService, 31 ) {} 32 33 /** Create a per-request CVParser using the user's resolved provider */ 34 private async createParser(user: User): Promise<CVParser> { 35 const provider = await this.providerResolver.resolveForUser(user); 36 return new CVParser(provider); 37 } 38 39 async parseFile( 40 user: User, 41 buffer: Buffer, 42 mimeType: string, 43 originalName: string, 44 ): Promise<ParsedCVData> { 45 const validation = validateFile({ 46 buffer, 47 mimeType, 48 originalName, 49 sizeBytes: buffer.length, 50 }); 51 52 if (!validation.valid) { 53 throw new Error(`File validation failed: ${validation.error}`); 54 } 55 56 const extraction = await this.textExtractorRegistry.extract( 57 buffer, 58 mimeType, 59 ); 60 61 if (!extraction.success) { 62 throw new Error(`Text extraction failed: ${extraction.error}`); 63 } 64 65 if (!extraction.text || extraction.text.trim().length === 0) { 66 throw new Error("Could not extract any text from the file"); 67 } 68 69 const parser = await this.createParser(user); 70 return parser.parseCVText(extraction.text); 71 } 72 73 async parseStory(user: User, storyText: string): Promise<ParsedCVData> { 74 if (!storyText || storyText.trim().length === 0) { 75 throw new Error("Story text cannot be empty"); 76 } 77 78 if (storyText.trim().length > 50000) { 79 throw new Error("Story text is too long (max 50,000 characters)"); 80 } 81 82 const parser = await this.createParser(user); 83 return parser.parseCVText(storyText); 84 } 85 86 /** 87 * Parse story text and resolve entities to existing database records 88 * Returns draft data with entity IDs where matches were found 89 */ 90 async parseStoryWithResolution( 91 user: User, 92 storyText: string, 93 ): Promise<ParsedCVDataWithResolution> { 94 const parsed = await this.parseStory(user, storyText); 95 return this.resolveEntities(parsed); 96 } 97 98 /** 99 * Parse file and resolve entities to existing database records 100 */ 101 async parseFileWithResolution( 102 user: User, 103 buffer: Buffer, 104 mimeType: string, 105 originalName: string, 106 ): Promise<ParsedCVDataWithResolution> { 107 const parsed = await this.parseFile(user, buffer, mimeType, originalName); 108 return this.resolveEntities(parsed); 109 } 110 111 /** 112 * Resolve entity names to existing database records 113 */ 114 private async resolveEntities( 115 parsed: ParsedCVData, 116 ): Promise<ParsedCVDataWithResolution> { 117 const [jobExperiences, education] = await Promise.all([ 118 this.resolveJobExperiences(parsed.jobExperiences), 119 this.resolveEducation(parsed.education), 120 ]); 121 122 return { jobExperiences, education }; 123 } 124 125 /** 126 * Resolve job experience entities 127 */ 128 private async resolveJobExperiences( 129 jobs: ParsedCVData["jobExperiences"], 130 ): Promise<ResolvedJobExperience[]> { 131 return Promise.all( 132 jobs.map(async (job) => { 133 const [company, role, level, skills] = await Promise.all([ 134 this.entityResolver.resolveCompany(job.companyName), 135 this.entityResolver.resolveRole(job.roleName), 136 this.entityResolver.resolveLevel(job.levelName), 137 this.entityResolver.resolveSkills(job.skills ?? []), 138 ]); 139 140 return { 141 company, 142 role, 143 level, 144 skills, 145 startDate: new Date(job.startDate), 146 endDate: job.endDate ? new Date(job.endDate) : null, 147 description: job.description ?? null, 148 }; 149 }), 150 ); 151 } 152 153 /** 154 * Resolve education entities 155 */ 156 private async resolveEducation( 157 education: ParsedCVData["education"], 158 ): Promise<ResolvedEducation[]> { 159 return Promise.all( 160 education.map(async (edu) => { 161 const [institution, skills] = await Promise.all([ 162 this.entityResolver.resolveInstitution(edu.institutionName), 163 this.entityResolver.resolveSkills(edu.skills ?? []), 164 ]); 165 166 return { 167 institution, 168 degree: edu.degree, 169 fieldOfStudy: edu.fieldOfStudy ?? null, 170 skills, 171 startDate: new Date(edu.startDate), 172 endDate: edu.endDate ? new Date(edu.endDate) : null, 173 description: edu.description ?? null, 174 }; 175 }), 176 ); 177 } 178}