{"record":{"id":"7e82d8c2797fca36","repo":"we-promise/sure","slug":"could-not-find-categorizations-in-response","errorCode":null,"errorMessage":"Could not find categorizations in response","messagePattern":"Could not find categorizations in response","errorType":"exception","errorClass":"Provider::Openai::Error","httpStatus":null,"severity":"error","filePath":"app/models/provider/openai/auto_categorizer.rb","lineNumber":363,"sourceCode":"      raw = message_output&.dig(\"content\", 0, \"text\")\n\n      raise Provider::Openai::Error, \"No message content found in response\" if raw.nil?\n\n      JSON.parse(raw).dig(\"categorizations\")\n    rescue JSON::ParserError => e\n      raise Provider::Openai::Error, \"Invalid JSON in native categorization: #{e.message}\"\n    end\n\n    def extract_categorizations_generic(response)\n      raw = response.dig(\"choices\", 0, \"message\", \"content\")\n      parsed = parse_json_flexibly(raw)\n\n      # Handle different response formats from various LLMs\n      categorizations = parsed.dig(\"categorizations\") ||\n                        parsed.dig(\"results\") ||\n                        (parsed.is_a?(Array) ? parsed : nil)\n\n      raise Provider::Openai::Error, \"Could not find categorizations in response\" if categorizations.nil?\n\n      # Normalize field names (some LLMs use different naming)\n      categorizations.map do |cat|\n        {\n          \"transaction_id\" => cat[\"transaction_id\"] || cat[\"id\"] || cat[\"txn_id\"],\n          \"category_name\" => cat[\"category_name\"] || cat[\"category\"] || cat[\"name\"]\n        }\n      end\n    end\n\n    # Flexible JSON parsing that handles common LLM output issues\n    def parse_json_flexibly(raw)\n      return {} if raw.blank?\n\n      # Strip thinking model tags if present (e.g., <think>...</think>)\n      # The actual JSON output comes after the thinking block\n      cleaned = strip_thinking_tags(raw)\n","sourceCodeStart":345,"sourceCodeEnd":381,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/openai/auto_categorizer.rb#L345-L381","documentation":"Raised by Provider::Openai::AutoCategorizer#extract_categorizations_generic when the chat-completions content parses to JSON but contains none of the accepted shapes: a categorizations key, a results key, or a top-level array. This path exists precisely because different models name things differently; when the model returns, say, {\"transactions\": [...]} or a per-transaction object keyed by id, the extractor gives up. It runs after parse_json_flexibly succeeded, so the JSON itself was valid.","triggerScenarios":"Model returns {\"transactions\": [{id, category}]} instead of categorizations/results; model nests one level deeper ({\"output\": {\"categorizations\": ...}}); model answers with an object of transaction_id -> category_name pairs; empty object {} for a batch the model 'had no opinion' about.","commonSituations":"Swapping the LLM behind a custom uri_base (Llama/Qwen/DeepSeek naming habits differ from GPT); prompt edits that paraphrase the requested schema; models returning an error note as JSON like {\"error\": \"too many transactions\"}.","solutions":["Log the parsed JSON keys at the failure point to learn the model's actual field name.","Add the observed key to the accepted list (e.g. parsed.dig(\"transactions\")) or normalize nested shapes before the nil check.","Make the prompt include a literal example response {\"categorizations\": [{\"transaction_id\": ..., \"category_name\": ...}]} — few-shot shape anchoring fixes most renames.","For custom gateways, prefer a model/endpoint that follows the demonstrated schema, or use JSON schema/response_format strictness."],"exampleFix":"# before\ncategorizations = parsed.dig(\"categorizations\") ||\n                  parsed.dig(\"results\") ||\n                  (parsed.is_a?(Array) ? parsed : nil)\n\n# after\ncategorizations = parsed.dig(\"categorizations\") ||\n                  parsed.dig(\"results\") ||\n                  parsed.dig(\"transactions\") ||\n                  (parsed.is_a?(Array) ? parsed : nil)","handlingStrategy":"fallback","validationCode":null,"typeGuard":null,"tryCatchPattern":"begin\n  extract_categorizations_generic(response)\nrescue Provider::Openai::Error => e\n  raise unless e.message.include?(\"Could not find categorizations\")\n  [] # treat as 'model had no categorizations' and surface partial results\nend","preventionTips":["Anchor the schema with a one-shot example response in the prompt when swapping models.","Log parsed top-level keys whenever extraction fails so new field names get added deliberately.","Keep the accepted-key list in sync with the models you route through the generic path."],"tags":["openai","llm-output","schema-mismatch","auto-categorization"],"backgroundTag":"llm-invalid-json","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}