personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix Gemini response parsing for non-standard JSON shapes

Gemini gemini-3-flash-preview intermittently returns responses in
unexpected shapes: bare arrays, array-wrapped dicts ([{"segments": [...]}]),
or alternate key names. This caused AttributeError crashes and silent
0-segment results in the transcribe pipeline.

Extract parsing into _extract_segments() that handles all observed shapes.
Apply same bare-list fix to enrich module. Add debug logging for raw
Gemini responses under -d flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+132 -7
+7 -2
observe/enrich.py
··· 117 117 result = json.loads(response_text) 118 118 logger.info(f" Enrichment complete in {time.perf_counter() - t0:.2f}s") 119 119 120 - # Validate response structure 120 + # Normalize response — Gemini sometimes returns a bare list of 121 + # statement dicts instead of the expected wrapper object. 122 + if isinstance(result, list): 123 + logger.warning("Enrichment returned bare list, wrapping as statements") 124 + result = {"statements": result, "topics": "", "setting": "", "warning": ""} 125 + 121 126 if not isinstance(result, dict): 122 - logger.warning(f"Enrichment returned non-dict: {type(result)}") 127 + logger.warning(f"Enrichment returned unexpected type: {type(result)}") 123 128 return None 124 129 125 130 if "statements" not in result or "topics" not in result:
+56 -5
observe/transcribe/gemini.py
··· 123 123 return None 124 124 125 125 126 + def _extract_segments(result: list | dict) -> list: 127 + """Extract a segments list from Gemini's JSON response. 128 + 129 + Gemini may return any of these shapes: 130 + - ``{"segments": [...]}`` — expected format per prompt 131 + - ``[{"start": ..., "text": ...}, ...]`` — bare list of segment dicts 132 + - ``[{"segments": [...]}]`` — array-wrapped dict 133 + - ``{"transcript": [...]}`` or other single-key wrapper 134 + 135 + Args: 136 + result: Parsed JSON (list or dict). 137 + 138 + Returns: 139 + List of segment dicts, empty list on unexpected input. 140 + """ 141 + # Unwrap: [{"segments": [...]}] — array-wrapped dict with a segments key. 142 + # Only unwrap if the inner dict has "segments", not if it's a segment itself. 143 + if ( 144 + isinstance(result, list) 145 + and len(result) == 1 146 + and isinstance(result[0], dict) 147 + and "segments" in result[0] 148 + ): 149 + result = result[0] 150 + 151 + # Bare list of segment dicts 152 + if isinstance(result, list): 153 + return result 154 + 155 + if isinstance(result, dict): 156 + # Preferred key 157 + if "segments" in result: 158 + val = result["segments"] 159 + if isinstance(val, list): 160 + return val 161 + 162 + # Fallback: single-key dict whose value is a list (e.g. {"transcript": [...]}) 163 + if len(result) == 1: 164 + val = next(iter(result.values())) 165 + if isinstance(val, list): 166 + logger.warning( 167 + f"Gemini used unexpected key {next(iter(result.keys()))!r} " 168 + f"instead of 'segments'" 169 + ) 170 + return val 171 + 172 + logger.warning(f"Gemini returned unexpected result shape: {type(result)}") 173 + return [] 174 + 175 + 126 176 def _build_chunk_contents( 127 177 audio: np.ndarray, 128 178 sample_rate: int, ··· 333 383 ) 334 384 335 385 transcribe_time = time.perf_counter() - t0 386 + logger.debug("Gemini raw response (%d chars):\n%s", len(response_text), response_text[:2000]) 336 387 337 388 # Parse JSON response 338 389 try: ··· 342 393 logger.debug(f"Response text: {response_text[:500]}") 343 394 raise RuntimeError(f"Gemini returned invalid JSON: {e}") from e 344 395 345 - # Extract segments 346 - segments = result.get("segments", []) 347 - if not isinstance(segments, list): 348 - logger.warning(f"Gemini 'segments' is not a list: {type(segments)}") 349 - segments = [] 396 + # Extract segments — Gemini may return different shapes: 397 + # [...] bare list of segments 398 + # {"segments": [...]} expected wrapper 399 + # {"transcript": [...]} alternate key name 400 + segments = _extract_segments(result) 350 401 351 402 # Normalize to standard statement format 352 403 if use_chunks:
+24
tests/test_enrich.py
··· 133 133 134 134 assert result is None 135 135 136 + @patch("observe.enrich.generate") 137 + def test_bare_list_response_wrapped(self, mock_generate): 138 + """Should wrap bare list response as statements with empty metadata.""" 139 + from observe.enrich import enrich_transcript 140 + 141 + wav = np.zeros(16000 * 10, dtype=np.float32) 142 + # Gemini returns bare list instead of {"statements": [...], "topics": ...} 143 + mock_generate.return_value = json.dumps( 144 + [ 145 + {"corrected": "Hello world.", "emotion": "calm"}, 146 + ] 147 + ) 148 + 149 + statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello world."}] 150 + 151 + result = enrich_transcript(wav, 16000, statements) 152 + 153 + assert result is not None 154 + assert result["statements"] == [ 155 + {"corrected": "Hello world.", "emotion": "calm"} 156 + ] 157 + assert result["topics"] == "" 158 + assert result["setting"] == "" 159 + 136 160 def test_returns_none_for_empty_statements(self): 137 161 """Should return None for empty statement list.""" 138 162 from observe.enrich import enrich_transcript
+45
tests/test_transcribe_gemini.py
··· 7 7 8 8 from observe.transcribe.gemini import ( 9 9 _build_chunk_contents, 10 + _extract_segments, 10 11 _find_segment_for_timestamp, 11 12 _format_timestamp, 12 13 _normalize_chunked_segments, ··· 255 256 256 257 # Should have: prompt + 2 valid chunks * 2 = 5 items 257 258 assert len(contents) == 5 259 + 260 + 261 + class TestExtractSegments: 262 + """Tests for _extract_segments — robust response parsing.""" 263 + 264 + def test_expected_dict_wrapper(self): 265 + """Standard {"segments": [...]} response.""" 266 + segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}] 267 + assert _extract_segments({"segments": segs}) == segs 268 + 269 + def test_bare_list(self): 270 + """Gemini returns bare list of segment dicts.""" 271 + segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}] 272 + assert _extract_segments(segs) == segs 273 + 274 + def test_alternate_key(self): 275 + """Single-key dict with alternate key name.""" 276 + segs = [{"start": "00:00", "text": "Hi"}] 277 + assert _extract_segments({"transcript": segs}) == segs 278 + 279 + def test_array_wrapped_dict(self): 280 + """Gemini wraps response in array: [{"segments": [...]}].""" 281 + segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}] 282 + assert _extract_segments([{"segments": segs}]) == segs 283 + 284 + def test_empty_segments(self): 285 + """Empty segments list in dict.""" 286 + assert _extract_segments({"segments": []}) == [] 287 + 288 + def test_empty_bare_list(self): 289 + """Empty bare list.""" 290 + assert _extract_segments([]) == [] 291 + 292 + def test_non_list_segments_value(self): 293 + """segments key has non-list value.""" 294 + assert _extract_segments({"segments": "not a list"}) == [] 295 + 296 + def test_unexpected_type(self): 297 + """Completely unexpected type returns empty.""" 298 + assert _extract_segments("unexpected") == [] 299 + 300 + def test_dict_with_no_segments_key(self): 301 + """Dict without segments or single-list key returns empty.""" 302 + assert _extract_segments({"other": "value", "more": "stuff"}) == [] 258 303 259 304 260 305 class TestGetModelInfo: