{"record":{"id":"e0971f53673d4ad3","repo":"koala73/worldmonitor","slug":"exa-returned-malformed-structured-summary-for-ur","errorCode":null,"errorMessage":"Exa returned malformed structured summary for ${url}","messagePattern":"Exa returned malformed structured summary for (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"consumer-prices-core/src/acquisition/exa.ts","lineNumber":107,"sourceCode":"        .filter(([, field]) => field.required !== false)\n        .map(([key]) => key),\n      additionalProperties: false,\n    };\n\n    // exa-js does not expose an AbortSignal for getContents, so use the API\n    // directly here to keep the opt-in fallback genuinely bounded.\n    // `text` rides along in the same call as ground truth for the caller's\n    // on-page price verification (price-evidence.ts) — same request, no\n    // second fetch.\n    const result = await this.request<{ results?: Array<{ summary?: unknown; text?: unknown }> }>('/contents', {\n      urls: [url],\n      summary: { query: prompt, schema: outputSchema },\n      text: { maxCharacters: 30_000 },\n    }, opts.timeout);\n    const item = result.results?.[0];\n    if (!item) throw new Error(`Exa returned no content for ${url}`);\n    const data = parseStructuredSummary<T>(item.summary);\n    if (data === null) throw new Error(`Exa returned malformed structured summary for ${url}`);\n\n    return {\n      url,\n      data,\n      provider: this.name,\n      fetchedAt: new Date(),\n      ...(typeof item.text === 'string' && item.text.trim() ? { pageContent: item.text } : {}),\n    };\n  }\n\n  async validate(): Promise<boolean> {\n    try {\n      await this.client.search('test', { numResults: 1 });\n      return true;\n    } catch {\n      return false;\n    }\n  }","sourceCodeStart":89,"sourceCodeEnd":125,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/consumer-prices-core/src/acquisition/exa.ts#L89-L125","documentation":"After getting a result row, extract() runs parseStructuredSummary on item.summary: an object passes through, a string is fence-stripped (```json) and JSON.parsed. null — and this throw — means the summary was absent, a non-string primitive, or a string whose JSON either failed to parse or parsed to a non-object: the model returned prose instead of schema-conformant JSON.","triggerScenarios":"Exa's summary model answering with narrative text instead of JSON (nondeterministic); the summary.schema not being honored by the API tier in use so no structured summary comes back; schema/prompt drift after an Exa model or API update.","commonSituations":"Sporadic malformed outputs sprinkled across a large extraction batch; behavior change after an Exa API/model revision; overly long field descriptions pushing the model off-format.","solutions":["Retry the call once — off-format model output is frequently transient","Simplify and tighten schema field descriptions so the model stays in structured mode","Confirm the summary.schema parameter is still supported for your Exa plan/API version","Treat as a page-level failure: record the miss and advance to the next candidate URL rather than disabling the provider"],"exampleFix":"// before\nconst data = parseStructuredSummary<T>(item.summary);\nif (data === null) throw new Error(`Exa returned malformed structured summary for ${url}`);\n\n// after — one retry for transient off-format output, then record the miss\nlet data = parseStructuredSummary<T>(item.summary);\nif (data === null) {\n  const retry = await this.request('/contents', body, opts.timeout);\n  data = parseStructuredSummary<T>(retry.results?.[0]?.summary);\n}\nif (data === null) return { url, data: null, provider: this.name, fetchedAt: new Date() };","handlingStrategy":"retry","validationCode":null,"typeGuard":"// Narrow a summary payload before trusting it (mirrors parseStructuredSummary)\nfunction isStructuredSummary<T>(v: unknown): v is T {\n  if (v && typeof v === 'object') return true;\n  if (typeof v !== 'string') return false;\n  try {\n    const p = JSON.parse(v.trim().replace(/^```(?:json)?\\s*/i, '').replace(/\\s*```$/, ''));\n    return !!p && typeof p === 'object';\n  } catch { return false; }\n}","tryCatchPattern":"try {\n  return await exa.extract(url, schema);\n} catch (err) {\n  if (err instanceof Error && /malformed structured summary/.test(err.message)) {\n    return await exa.extract(url, schema);   // model output is nondeterministic — retry once\n  }\n  throw err;\n}","preventionTips":["Retry off-format summaries once; log the raw summary on the second failure for schema tuning","Keep schema field descriptions short and imperative to hold the model in JSON mode","Re-verify extraction quality after any Exa model/API update"],"tags":["exa","llm","json","structured-output"],"backgroundTag":"llm-malformed-json","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-08-23T13:39:53.451Z"}