Skip to content

Commit bd60722

Browse files
authored
fix: handle LLM responses wrapped in markdown code fences (#3972)
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent 9a220a6 commit bd60722

5 files changed

Lines changed: 91 additions & 10 deletions

File tree

services/apps/organizations_enrichment_worker/src/activities/llm.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseLlmJson } from '@crowd/common'
12
import { LlmService } from '@crowd/common_services'
23
import {
34
OrganizationField,
@@ -132,7 +133,7 @@ export async function selectMostRelevantDomainWithLLM(
132133
organizationId,
133134
)
134135
if (!response) throw new Error('LLM returned no response')
135-
return JSON.parse(response.answer) as LlmDomainSelection
136+
return parseLlmJson<LlmDomainSelection>(response.answer)
136137
}
137138

138139
const MAX_RETRIES = 1

services/apps/profiles_worker/src/workflows/member/processMemberBotAnalysisWithLLM.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { proxyActivities } from '@temporalio/workflow'
22

3+
import { parseLlmJson } from '@crowd/common'
34
import { LlmQueryType } from '@crowd/types'
45

56
import * as activities from '../../activities'
@@ -72,7 +73,7 @@ export async function processMemberBotAnalysisWithLLM(
7273

7374
const llm = await getLLMResult(LlmQueryType.MEMBER_BOT_VALIDATION, PROMPT, memberId)
7475

75-
const { isBot, signals } = JSON.parse(llm.answer) as MemberBotSuggestionResult
76+
const { isBot, signals } = parseLlmJson<MemberBotSuggestionResult>(llm.answer)
7677

7778
if (!isBot) {
7879
await createMemberNoBot(memberId)

services/libs/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export * from './displayName'
3636
export * from './jira'
3737
export * from './email'
3838
export * from './bot'
39+
export * from './llm'
3940

4041
export * from './i18n'
4142
export * from './member'

services/libs/common/src/llm.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
export function parseLlmJson<T>(answer: string): T {
2+
const raw = answer.trim()
3+
4+
let lastError: string | undefined
5+
6+
const tryParse = (str: string): T | undefined => {
7+
try {
8+
return JSON.parse(str) as T
9+
} catch (e) {
10+
lastError = (e as Error).message
11+
return undefined
12+
}
13+
}
14+
15+
// 1. Direct parse
16+
const direct = tryParse(raw)
17+
if (direct !== undefined) return direct
18+
19+
// 2. Fenced ```json``` block
20+
const fenced = raw.match(/^```(?:json)?\n([\s\S]*?)\n```$/i)?.[1]
21+
if (fenced) {
22+
const parsed = tryParse(fenced.trim())
23+
if (parsed !== undefined) return parsed
24+
}
25+
26+
// 3. Balanced extraction — try each delimiter type, scanning forward on failed parse
27+
const delimiters = [
28+
...([
29+
['{', '}'],
30+
['[', ']'],
31+
] as const),
32+
].sort((a, b) => {
33+
const ia = raw.indexOf(a[0])
34+
const ib = raw.indexOf(b[0])
35+
if (ia === -1) return 1
36+
if (ib === -1) return -1
37+
return ia - ib
38+
})
39+
40+
for (const [open, close] of delimiters) {
41+
let start = raw.indexOf(open)
42+
43+
while (start >= 0) {
44+
let depth = 0,
45+
inString = false,
46+
escaped = false
47+
48+
for (let i = start; i < raw.length; i++) {
49+
const char = raw[i]
50+
51+
if (inString) {
52+
if (escaped) escaped = false
53+
else if (char === '\\') escaped = true
54+
else if (char === '"') inString = false
55+
continue
56+
}
57+
58+
if (char === '"') {
59+
inString = true
60+
} else if (char === open) {
61+
depth++
62+
} else if (char === close && --depth === 0) {
63+
const parsed = tryParse(raw.slice(start, i + 1))
64+
if (parsed !== undefined) return parsed
65+
start = raw.indexOf(open, i + 1)
66+
break
67+
}
68+
}
69+
70+
if (depth !== 0) break
71+
}
72+
}
73+
74+
const preview = raw.length > 200 ? `${raw.slice(0, 200)}…` : raw
75+
throw new SyntaxError(
76+
`LLM response does not contain valid JSON: ${lastError} | Input: ${preview}`,
77+
)
78+
}

services/libs/common_services/src/services/llm.service.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from '@aws-sdk/client-bedrock-runtime'
66
import { performance } from 'perf_hooks'
77

8-
import { IS_LLM_ENABLED } from '@crowd/common'
8+
import { IS_LLM_ENABLED, parseLlmJson } from '@crowd/common'
99
import { insertPromptHistoryEntry } from '@crowd/data-access-layer'
1010
import { QueryExecutor } from '@crowd/data-access-layer'
1111
import { Logger, LoggerBase } from '@crowd/logging'
@@ -177,7 +177,7 @@ export class LlmService extends LoggerBase {
177177
} as ILlmResult<LlmMemberEnrichmentResult>
178178
}
179179

180-
const result = JSON.parse(response.answer)
180+
const result = parseLlmJson<LlmMemberEnrichmentResult>(response.answer)
181181

182182
return {
183183
result,
@@ -200,7 +200,7 @@ export class LlmService extends LoggerBase {
200200
} as ILlmResult<{ profileIndex: number }>
201201
}
202202

203-
const result = JSON.parse(response.answer)
203+
const result = parseLlmJson<{ profileIndex: number }>(response.answer)
204204

205205
return {
206206
result,
@@ -224,7 +224,7 @@ export class LlmService extends LoggerBase {
224224
} as ILlmResult<T>
225225
}
226226

227-
const result = JSON.parse(response.answer)
227+
const result = parseLlmJson<T>(response.answer)
228228

229229
return {
230230
result,
@@ -248,7 +248,7 @@ export class LlmService extends LoggerBase {
248248
} as ILlmResult<T>
249249
}
250250

251-
const result = JSON.parse(response.answer)
251+
const result = parseLlmJson<T>(response.answer)
252252

253253
return {
254254
result,
@@ -268,7 +268,7 @@ export class LlmService extends LoggerBase {
268268
} as ILlmResult<T>
269269
}
270270

271-
const result = JSON.parse(response.answer)
271+
const result = parseLlmJson<T>(response.answer)
272272

273273
return {
274274
result,
@@ -285,7 +285,7 @@ export class LlmService extends LoggerBase {
285285
} as ILlmResult<T>
286286
}
287287

288-
const result = JSON.parse(response.answer)
288+
const result = parseLlmJson<T>(response.answer)
289289

290290
return {
291291
result,
@@ -302,7 +302,7 @@ export class LlmService extends LoggerBase {
302302
} as ILlmResult<T>
303303
}
304304

305-
const result = JSON.parse(response.answer)
305+
const result = parseLlmJson<T>(response.answer)
306306

307307
return {
308308
result,

0 commit comments

Comments
 (0)