fix(backend): parse triage proposals wrapped in prose or code fences

Free-model routers often answer with surrounding prose, so a strict whole-
response JSON parse failed and every triage run ended in error. Extract the
first JSON block before parsing, keeping the zod schema gate intact.
This commit is contained in:
lakshit verma 2026-08-19 22:05:20 +05:30
parent f117d78baf
commit 35069fe4ad
No known key found for this signature in database
2 changed files with 29 additions and 5 deletions

View file

@ -197,14 +197,26 @@ function buildUserContent(wo: WorkOrderDoc, lastError: string | null): string {
type ProposalParse = { ok: true; data: TriageProposal } | { ok: false; message: string };
function extractJsonBlock(text: string): string | null {
const trimmed = text.trim();
const fenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
for (const candidate of [fenced, trimmed]) {
const start = candidate.search(/[{[]/);
if (start === -1) continue;
const open = candidate[start] as '{' | '[';
const close = open === '{' ? '}' : ']';
const end = candidate.lastIndexOf(close);
if (end > start) return candidate.slice(start, end + 1);
}
return null;
}
function parseProposal(content: string): ProposalParse {
const cleaned = content
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/, '');
const block = extractJsonBlock(content);
if (!block) return { ok: false, message: 'the response did not contain a JSON object' };
let obj: unknown;
try {
obj = JSON.parse(cleaned);
obj = JSON.parse(block);
} catch {
return { ok: false, message: 'the response was not valid JSON' };
}

View file

@ -151,6 +151,18 @@ describe('triage agent', () => {
expect(providerMock.chatComplete).not.toHaveBeenCalled();
});
it('parses a proposal wrapped in prose and code fences', async () => {
const { wo } = await makeWorkOrder();
providerMock.chatComplete.mockResolvedValue(
providerResult({ content: `Sure, here you go:\n\`\`\`json\n${proposal()}\n\`\`\`\nHope that helps!` }),
);
const outcome = await runTriage(wo.id);
expect(outcome.outcome).toBe('done');
const suggestions = await TriageSuggestion.find({ workOrderId: wo.id });
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({ suggestedPriority: 'high' });
});
it('returns retry when the provider fails transiently and records AI_UNAVAILABLE', async () => {
const { wo } = await makeWorkOrder();
providerMock.chatComplete.mockRejectedValue(new providerMock.ProviderError('upstream down', 'network'));