because I got bored of customising my CV for every job
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}