diff --git a/observe/enrich.py b/observe/enrich.py index f3e622d89..9f693713d 100644 --- a/observe/enrich.py +++ b/observe/enrich.py @@ -117,9 +117,14 @@ def enrich_transcript( result = json.loads(response_text) logger.info(f" Enrichment complete in {time.perf_counter() - t0:.2f}s") - # Validate response structure + # Normalize response — Gemini sometimes returns a bare list of + # statement dicts instead of the expected wrapper object. + if isinstance(result, list): + logger.warning("Enrichment returned bare list, wrapping as statements") + result = {"statements": result, "topics": "", "setting": "", "warning": ""} + if not isinstance(result, dict): - logger.warning(f"Enrichment returned non-dict: {type(result)}") + logger.warning(f"Enrichment returned unexpected type: {type(result)}") return None if "statements" not in result or "topics" not in result: diff --git a/observe/transcribe/gemini.py b/observe/transcribe/gemini.py index 27c855fb1..46fb0c153 100644 --- a/observe/transcribe/gemini.py +++ b/observe/transcribe/gemini.py @@ -123,6 +123,56 @@ def _parse_speaker(speaker: str | int | None) -> int | None: return None +def _extract_segments(result: list | dict) -> list: + """Extract a segments list from Gemini's JSON response. + + Gemini may return any of these shapes: + - ``{"segments": [...]}`` — expected format per prompt + - ``[{"start": ..., "text": ...}, ...]`` — bare list of segment dicts + - ``[{"segments": [...]}]`` — array-wrapped dict + - ``{"transcript": [...]}`` or other single-key wrapper + + Args: + result: Parsed JSON (list or dict). + + Returns: + List of segment dicts, empty list on unexpected input. + """ + # Unwrap: [{"segments": [...]}] — array-wrapped dict with a segments key. + # Only unwrap if the inner dict has "segments", not if it's a segment itself. + if ( + isinstance(result, list) + and len(result) == 1 + and isinstance(result[0], dict) + and "segments" in result[0] + ): + result = result[0] + + # Bare list of segment dicts + if isinstance(result, list): + return result + + if isinstance(result, dict): + # Preferred key + if "segments" in result: + val = result["segments"] + if isinstance(val, list): + return val + + # Fallback: single-key dict whose value is a list (e.g. {"transcript": [...]}) + if len(result) == 1: + val = next(iter(result.values())) + if isinstance(val, list): + logger.warning( + f"Gemini used unexpected key {next(iter(result.keys()))!r} " + f"instead of 'segments'" + ) + return val + + logger.warning(f"Gemini returned unexpected result shape: {type(result)}") + return [] + + def _build_chunk_contents( audio: np.ndarray, sample_rate: int, @@ -333,6 +383,7 @@ def transcribe( ) transcribe_time = time.perf_counter() - t0 + logger.debug("Gemini raw response (%d chars):\n%s", len(response_text), response_text[:2000]) # Parse JSON response try: @@ -342,11 +393,11 @@ def transcribe( logger.debug(f"Response text: {response_text[:500]}") raise RuntimeError(f"Gemini returned invalid JSON: {e}") from e - # Extract segments - segments = result.get("segments", []) - if not isinstance(segments, list): - logger.warning(f"Gemini 'segments' is not a list: {type(segments)}") - segments = [] + # Extract segments — Gemini may return different shapes: + # [...] bare list of segments + # {"segments": [...]} expected wrapper + # {"transcript": [...]} alternate key name + segments = _extract_segments(result) # Normalize to standard statement format if use_chunks: diff --git a/tests/test_enrich.py b/tests/test_enrich.py index 87991be69..3e6f97f1f 100644 --- a/tests/test_enrich.py +++ b/tests/test_enrich.py @@ -133,6 +133,30 @@ class TestEnrichTranscript: assert result is None + @patch("observe.enrich.generate") + def test_bare_list_response_wrapped(self, mock_generate): + """Should wrap bare list response as statements with empty metadata.""" + from observe.enrich import enrich_transcript + + wav = np.zeros(16000 * 10, dtype=np.float32) + # Gemini returns bare list instead of {"statements": [...], "topics": ...} + mock_generate.return_value = json.dumps( + [ + {"corrected": "Hello world.", "emotion": "calm"}, + ] + ) + + statements = [{"id": 1, "start": 0.0, "end": 2.0, "text": "Hello world."}] + + result = enrich_transcript(wav, 16000, statements) + + assert result is not None + assert result["statements"] == [ + {"corrected": "Hello world.", "emotion": "calm"} + ] + assert result["topics"] == "" + assert result["setting"] == "" + def test_returns_none_for_empty_statements(self): """Should return None for empty statement list.""" from observe.enrich import enrich_transcript diff --git a/tests/test_transcribe_gemini.py b/tests/test_transcribe_gemini.py index a70656ae4..46955b584 100644 --- a/tests/test_transcribe_gemini.py +++ b/tests/test_transcribe_gemini.py @@ -7,6 +7,7 @@ import numpy as np from observe.transcribe.gemini import ( _build_chunk_contents, + _extract_segments, _find_segment_for_timestamp, _format_timestamp, _normalize_chunked_segments, @@ -257,6 +258,50 @@ class TestBuildChunkContents: assert len(contents) == 5 +class TestExtractSegments: + """Tests for _extract_segments — robust response parsing.""" + + def test_expected_dict_wrapper(self): + """Standard {"segments": [...]} response.""" + segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}] + assert _extract_segments({"segments": segs}) == segs + + def test_bare_list(self): + """Gemini returns bare list of segment dicts.""" + segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}] + assert _extract_segments(segs) == segs + + def test_alternate_key(self): + """Single-key dict with alternate key name.""" + segs = [{"start": "00:00", "text": "Hi"}] + assert _extract_segments({"transcript": segs}) == segs + + def test_array_wrapped_dict(self): + """Gemini wraps response in array: [{"segments": [...]}].""" + segs = [{"start": "00:00", "speaker": "Speaker 1", "text": "Hi"}] + assert _extract_segments([{"segments": segs}]) == segs + + def test_empty_segments(self): + """Empty segments list in dict.""" + assert _extract_segments({"segments": []}) == [] + + def test_empty_bare_list(self): + """Empty bare list.""" + assert _extract_segments([]) == [] + + def test_non_list_segments_value(self): + """segments key has non-list value.""" + assert _extract_segments({"segments": "not a list"}) == [] + + def test_unexpected_type(self): + """Completely unexpected type returns empty.""" + assert _extract_segments("unexpected") == [] + + def test_dict_with_no_segments_key(self): + """Dict without segments or single-list key returns empty.""" + assert _extract_segments({"other": "value", "more": "stuff"}) == [] + + class TestGetModelInfo: """Tests for get_model_info function."""