Skip to content

Commit 06e6898

Browse files
capJavertclaude
andcommitted
feat: expose user-friendly parse error for opportunity parsing
When Brokkr detects invalid document format (e.g., multiple job opportunities in a single file), save the error message as parseErrorUserMessage in opportunity flags and expose it via GraphQL so the frontend can show it instead of a generic error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f632945 commit 06e6898

8 files changed

Lines changed: 99 additions & 7 deletions

File tree

__tests__/helpers.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,19 @@ export const createMockBrokkrTransport = ({
561561
});
562562
});
563563

564+
export const createMockBrokkrParseGeneralErrorTransport = (
565+
errorMessage: string,
566+
) =>
567+
createRouterTransport(({ service }) => {
568+
service(BrokkrService, {
569+
parseOpportunity: () => {
570+
return new ParseOpportunityResponse({
571+
errors: [new ParseError({ field: 'general', message: errorMessage })],
572+
});
573+
},
574+
});
575+
});
576+
564577
export const createMockNjordTransport = () => {
565578
return createRouterTransport(({ service }) => {
566579
const accounts: Record<

__tests__/workers/opportunity/parseOpportunity.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
expectSuccessfulTypedBackground,
33
saveFixtures,
44
createMockBrokkrTransport,
5+
createMockBrokkrParseGeneralErrorTransport,
56
createGarmrMock,
67
} from '../../helpers';
78
import { parseOpportunityWorker as worker } from '../../../src/workers/opportunity/parseOpportunity';
@@ -285,6 +286,7 @@ describe('parseOpportunity worker', () => {
285286

286287
expect(opportunity!.state).toBe(OpportunityState.ERROR);
287288
expect(opportunity!.flags?.parseError).toContain('Brokkr parsing failed');
289+
expect(opportunity!.flags?.parseErrorUserMessage).toBeUndefined();
288290
});
289291

290292
it('should skip if state is not PARSING', async () => {
@@ -609,6 +611,60 @@ describe('parseOpportunity worker', () => {
609611
expect(recruiter).toBeNull();
610612
});
611613

614+
it('should set ERROR state with parseError when Brokkr returns general error', async () => {
615+
jest.restoreAllMocks();
616+
617+
const transport = createMockBrokkrParseGeneralErrorTransport(
618+
'The document is a list of multiple job opportunities, not a detailed description of a single one',
619+
);
620+
621+
jest.spyOn(brokkrCommon, 'getBrokkrClient').mockImplementation(
622+
(): ServiceClient<typeof BrokkrService> => ({
623+
instance: createClient(BrokkrService, transport),
624+
garmr: createGarmrMock(),
625+
}),
626+
);
627+
628+
mockStorageDownload.mockResolvedValue([Buffer.from('mock-pdf-content')]);
629+
mockStorageExists.mockResolvedValue([true]);
630+
631+
await con.getRepository(OpportunityJob).save({
632+
id: testOpportunityId,
633+
type: OpportunityType.JOB,
634+
state: OpportunityState.PARSING,
635+
title: 'Processing...',
636+
tldr: '',
637+
content: new OpportunityContent({}),
638+
flags: {
639+
batchSize: 100,
640+
file: {
641+
blobName: testBlobName,
642+
bucketName: RESUME_BUCKET_NAME,
643+
mimeType: 'application/pdf',
644+
extension: 'pdf',
645+
userId: testUserId,
646+
trackingId: 'anon1',
647+
},
648+
},
649+
});
650+
651+
await expectSuccessfulTypedBackground<'api.v1.opportunity-parse'>(worker, {
652+
opportunityId: testOpportunityId,
653+
});
654+
655+
const opportunity = await con.getRepository(OpportunityJob).findOne({
656+
where: { id: testOpportunityId },
657+
});
658+
659+
expect(opportunity!.state).toBe(OpportunityState.ERROR);
660+
expect(opportunity!.flags?.parseError).toBe(
661+
'The document is a list of multiple job opportunities, not a detailed description of a single one',
662+
);
663+
expect(opportunity!.flags?.parseErrorUserMessage).toBe(
664+
'The document is a list of multiple job opportunities, not a detailed description of a single one',
665+
);
666+
});
667+
612668
it('should assign Europe as continent when no country specified', async () => {
613669
// Create a custom mock with just continent: 'Europe' (no country)
614670
const transport = createMockBrokkrTransport({

src/common/opportunity/parse.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212

1313
import { getBufferFromStream } from '../utils';
1414
import { ValidationError } from 'apollo-server-errors';
15+
import { ParseOpportunityError } from '../../errors';
1516
import { garmScraperService } from '../scraper';
1617
import { acceptedOpportunityFileTypes } from '../../types';
1718
import { getBrokkrClient } from '../brokkr';
@@ -210,6 +211,12 @@ export async function parseOpportunityWithBrokkr({
210211

211212
logger.info(result, 'brokkrParseOpportunityResponse');
212213

214+
const generalError = result.errors?.find((e) => e.field === 'general');
215+
216+
if (generalError) {
217+
throw new ParseOpportunityError(generalError.message);
218+
}
219+
213220
// Sanitize Brokkr response - filter out invalid locations
214221
const sanitizedOpportunity = {
215222
...result.opportunity,

src/entity/opportunities/Opportunity.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type OpportunityFlags = Partial<{
4444
trackingId?: string;
4545
} | null;
4646
parseError: string | null;
47+
parseErrorUserMessage: string | null;
4748
isTrial: boolean;
4849
public_draft: boolean;
4950
sourceUrl: string | null;
@@ -52,7 +53,7 @@ export type OpportunityFlags = Partial<{
5253

5354
export type OpportunityFlagsPublic = Pick<
5455
OpportunityFlags,
55-
'batchSize' | 'plan' | 'showSlack' | 'showFeedback'
56+
'batchSize' | 'plan' | 'showSlack' | 'showFeedback' | 'parseErrorUserMessage'
5657
>;
5758

5859
@Entity()

src/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ export class PaymentRequiredError extends ApolloError {
228228
}
229229
}
230230

231+
export class ParseOpportunityError extends Error {
232+
constructor(message: string) {
233+
super(message);
234+
this.name = 'ParseOpportunityError';
235+
}
236+
}
237+
231238
export class ServiceError extends Error {
232239
data?: JsonValue;
233240
statusCode?: number;

src/graphorm/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ import { extractHandleFromUrl } from '../common/schema/socials';
7575
import type { GCSBlob } from '../common/schema/userCandidate';
7676
import { QuestionType } from '../entity/questions/types';
7777
import { snotraClient } from '../integrations/snotra';
78-
import type { OpportunityFlagsPublic } from '../entity/opportunities/Opportunity';
78+
import type {
79+
OpportunityFlags,
80+
OpportunityFlagsPublic,
81+
} from '../entity/opportunities/Opportunity';
7982
import { isNullOrUndefined } from '../common/object';
8083

8184
export enum LocationVerificationStatus {
@@ -1903,12 +1906,13 @@ const obj = new GraphORM({
19031906
},
19041907
flags: {
19051908
jsonType: true,
1906-
transform: (value: OpportunityFlagsPublic): OpportunityFlagsPublic => {
1909+
transform: (value: OpportunityFlags): OpportunityFlagsPublic => {
19071910
return {
19081911
batchSize: value?.batchSize ?? opportunityMatchBatchSize,
19091912
plan: value?.plan,
19101913
showSlack: value?.showSlack ?? false,
19111914
showFeedback: value?.showFeedback ?? false,
1915+
parseErrorUserMessage: value?.parseErrorUserMessage,
19121916
};
19131917
},
19141918
},

src/schema/opportunity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export const typeDefs = /* GraphQL */ `
309309
plan: String
310310
showSlack: Boolean
311311
showFeedback: Boolean
312+
parseErrorUserMessage: String
312313
}
313314
314315
"""

src/workers/opportunity/parseOpportunity.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createOpportunityFromParsedData,
88
} from '../../common/opportunity/parse';
99
import { updateFlagsStatement } from '../../common';
10+
import { ParseOpportunityError } from '../../errors';
1011
import { deleteBlobFromGCS } from '../../common/googleCloud';
1112
import z from 'zod';
1213
import { performance } from 'perf_hooks';
@@ -167,6 +168,9 @@ export const parseOpportunityWorker: TypedWorker<'api.v1.opportunity-parse'> = {
167168
const errorMessage =
168169
error instanceof Error ? error.message : 'Unknown error';
169170

171+
const parseError =
172+
error instanceof z.ZodError ? z.prettifyError(error) : errorMessage;
173+
170174
await con
171175
.getRepository(OpportunityJob)
172176
.createQueryBuilder()
@@ -178,10 +182,9 @@ export const parseOpportunityWorker: TypedWorker<'api.v1.opportunity-parse'> = {
178182
.setParameter(
179183
'flagsJson',
180184
JSON.stringify({
181-
parseError:
182-
error instanceof z.ZodError
183-
? z.prettifyError(error)
184-
: errorMessage,
185+
parseError,
186+
parseErrorUserMessage:
187+
error instanceof ParseOpportunityError ? errorMessage : undefined,
185188
}),
186189
)
187190
.execute();

0 commit comments

Comments
 (0)