diff --git a/application/api/answer/services/compression/service.py b/application/api/answer/services/compression/service.py index 300f0357..3af3ac63 100644 --- a/application/api/answer/services/compression/service.py +++ b/application/api/answer/services/compression/service.py @@ -77,8 +77,11 @@ class CompressionService: # Get queries to compress queries_to_compress = queries[: compress_up_to_index + 1] - # Check if there are existing compressions - existing_compressions = conversation.get("compression_metadata", {}).get( + # Check if there are existing compressions. ``compression_metadata`` + # is a nullable JSONB column, so a never-compressed conversation + # reads back as None; ``get(key, {})`` would return that None (the + # default only applies to absent keys), so coalesce with ``or {}``. + existing_compressions = (conversation.get("compression_metadata") or {}).get( "compression_points", [] ) @@ -201,7 +204,9 @@ class CompressionService: (compressed_summary, recent_messages) """ try: - compression_metadata = conversation.get("compression_metadata", {}) + # ``or {}`` guards against a NULL ``compression_metadata`` column + # (reads back as None), which would crash the ``.get`` calls below. + compression_metadata = conversation.get("compression_metadata") or {} if not compression_metadata.get("is_compressed"): logger.debug("No compression metadata found - using full history") diff --git a/tests/test_compression_service.py b/tests/test_compression_service.py index 80efc7b0..0eaeed5f 100644 --- a/tests/test_compression_service.py +++ b/tests/test_compression_service.py @@ -641,6 +641,45 @@ class TestCompressionService: # Should log the error assert mock_logger.error.called + def test_compress_conversation_with_null_compression_metadata( + self, compression_service, sample_conversation + ): + """A never-compressed conversation stores ``compression_metadata`` as + SQL NULL, which reads back as ``None``. ``dict.get(key, {})`` returns + that ``None`` (the default only applies to *absent* keys), so the + chained ``.get("compression_points", [])`` raised ``AttributeError: + 'NoneType' object has no attribute 'get'`` and aborted compression. + It must compress normally instead. + """ + sample_conversation["compression_metadata"] = None + compression_service.llm.gen.return_value = "ok" + + result = compression_service.compress_conversation( + conversation=sample_conversation, compress_up_to_index=1 + ) + + assert result.query_index == 1 + assert "ok" in result.compressed_summary + + @patch("application.api.answer.services.compression.service.logger") + def test_get_compressed_context_with_null_compression_metadata( + self, mock_logger, compression_service, sample_conversation + ): + """The same ``None`` value must not crash ``get_compressed_context``. + Before the guard it raised, got swallowed by the except, and logged a + spurious error while falling back to full history. It should now take + the clean no-compression path: full history, no error logged. + """ + sample_conversation["compression_metadata"] = None + + summary, recent = compression_service.get_compressed_context( + sample_conversation + ) + + assert summary is None + assert len(recent) == len(sample_conversation["queries"]) + assert not mock_logger.error.called + def test_compression_points_array_limiting(self, compression_service): """Test that only the most recent compression points are kept"""