fix(notebooks): correct SUMMARIZE response parsing (fixes #147) (#150)

* fix(notebooks): correct SUMMARIZE response parsing for get_description/get_summary

The VfAZjd (SUMMARIZE) RPC returns a triple-nested structure:
  result = [[[summary_string], [[topics]], ...]]

The previous parsing assumed:
- Summary at result[0][0] (a string) — actually a list
- Topics at result[1][0] — actually at result[0][1][0]

This caused get_description() to always return an empty summary and no
suggested topics, showing "No summary available" in the CLI.

Fixed parsing to use:
- Summary: result[0][0][0]
- Topics:  result[0][1][0]

Updated all affected unit/integration tests to use the correct response
structure (confirmed against the real API cassette in notebooks_get_summary.yaml).

Fixes #147

* test(e2e): strengthen summary assertions to catch empty results

* refactor(notebooks): simplify SUMMARIZE response parsing per review feedback

* docs(rpc-reference): fix SUMMARIZE response structure (triple-nested)
This commit is contained in:
Teng Lin 2026-03-04 07:33:42 -08:00 committed by GitHub
parent 7939795fa2
commit 6a6e1a9484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 76 additions and 51 deletions

View file

@ -928,11 +928,15 @@ await rpc_call(
# Response structure:
# [
# [summary_text], # [0][0]: Summary string
# [[ # [1][0]: Suggested topics array
# [question, prompt], # Each topic has question and prompt
# ...
# ]],
# [ # [0]: Outer container
# [summary_text], # [0][0]: Summary wrapped in list; text at [0][0][0]
# [[ # [0][1][0]: Suggested topics array
# [question, prompt], # Each topic has question and prompt
# ...
# ]],
# null, null, null,
# [[question, score], ...], # [0][5]: Topics with relevance scores
# ]
# ]
```

View file

@ -135,8 +135,14 @@ class NotebooksAPI:
params,
source_path=f"/notebook/{notebook_id}",
)
if result and isinstance(result, list) and len(result) > 0:
return str(result[0]) if result[0] else ""
# Response structure: [[[summary_string, ...], topics, ...]]
# Summary is at result[0][0][0]
try:
if result and isinstance(result, list):
summary = result[0][0][0]
return str(summary) if summary else ""
except (IndexError, TypeError):
pass
return ""
async def get_description(self, notebook_id: str) -> NotebookDescription:
@ -168,22 +174,30 @@ class NotebooksAPI:
summary = ""
suggested_topics: list[SuggestedTopic] = []
# Response structure: [[[summary_string], [[topics]], ...]]
# Summary is at result[0][0][0], topics at result[0][1][0]
if result and isinstance(result, list):
# Summary at [0][0]
if len(result) > 0 and isinstance(result[0], list) and len(result[0]) > 0:
summary = result[0][0] if isinstance(result[0][0], str) else ""
try:
outer = result[0]
# Suggested topics at [1][0]
if len(result) > 1 and isinstance(result[1], list) and len(result[1]) > 0:
topics_list = result[1][0] if isinstance(result[1][0], list) else []
for topic in topics_list:
if isinstance(topic, list) and len(topic) >= 2:
suggested_topics.append(
SuggestedTopic(
question=topic[0] if isinstance(topic[0], str) else "",
prompt=topic[1] if isinstance(topic[1], str) else "",
# Summary at outer[0][0]
summary_val = outer[0][0]
summary = str(summary_val) if summary_val else ""
# Suggested topics at outer[1][0]
topics_list = outer[1][0]
if isinstance(topics_list, list):
for topic in topics_list:
if isinstance(topic, list) and len(topic) >= 2:
suggested_topics.append(
SuggestedTopic(
question=str(topic[0]) if topic[0] else "",
prompt=str(topic[1]) if topic[1] else "",
)
)
)
except (IndexError, TypeError):
# A partial result (e.g. summary but no topics) is possible.
pass
return NotebookDescription(summary=summary, suggested_topics=suggested_topics)

View file

@ -60,7 +60,7 @@ class TestNotebookDescription:
description = await client.notebooks.get_description(read_only_notebook_id)
assert isinstance(description, NotebookDescription)
assert description.summary is not None
assert description.summary, "Expected non-empty summary from get_description"
assert isinstance(description.suggested_topics, list)
@ -92,8 +92,7 @@ class TestNotebookSummary:
async def test_get_summary(self, client, read_only_notebook_id):
"""Test getting notebook summary."""
summary = await client.notebooks.get_summary(read_only_notebook_id)
# Summary may be empty string if not generated yet
assert isinstance(summary, str)
assert summary, "Expected non-empty summary from get_summary"
@pytest.mark.asyncio
@pytest.mark.readonly

View file

@ -201,7 +201,9 @@ class TestSummary:
httpx_mock: HTTPXMock,
build_rpc_response,
):
response = build_rpc_response(RPCMethod.SUMMARIZE, ["Summary of the notebook content..."])
response = build_rpc_response(
RPCMethod.SUMMARIZE, [[["Summary of the notebook content..."]]]
)
httpx_mock.add_response(content=response.encode())
async with NotebookLMClient(auth_tokens) as client:
@ -313,7 +315,7 @@ class TestNotebooksAPIAdditional:
"""Test getting notebook summary."""
response = build_rpc_response(
RPCMethod.SUMMARIZE,
["This is a comprehensive summary of the notebook content..."],
[[["This is a comprehensive summary of the notebook content..."]]],
)
httpx_mock.add_response(content=response.encode())
@ -372,13 +374,15 @@ class TestNotebooksAPIAdditional:
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[
["This notebook covers AI research."],
[
["This notebook covers AI research."],
[
["What are the main findings?", "Explain the key findings"],
["How was the study conducted?", "Describe methodology"],
]
],
[
["What are the main findings?", "Explain the key findings"],
["How was the study conducted?", "Describe methodology"],
]
],
]
],
)
httpx_mock.add_response(content=response.encode())
@ -522,7 +526,7 @@ class TestNotebookEdgeCases:
"""Test getting description with no suggested topics."""
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[["Summary text"], []],
[[["Summary text"], []]],
)
httpx_mock.add_response(content=response.encode())
@ -543,14 +547,16 @@ class TestNotebookEdgeCases:
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[
["Summary"],
[
["Summary"],
[
["Valid question", "Valid prompt"],
["Only question"], # Missing prompt
"not a list", # Not a list
]
],
[
["Valid question", "Valid prompt"],
["Only question"], # Missing prompt
"not a list", # Not a list
]
],
]
],
)
httpx_mock.add_response(content=response.encode())
@ -574,11 +580,11 @@ class TestDescribeEdgeCases:
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Line 171->188: result has only [0] (no result[1]) so topics stay empty."""
# result = [["A summary"]] — len is 1, so result[1] branch is never entered
"""result has only outer[0] (no outer[1]) so topics stay empty."""
# result = [[["A summary"]]] — outer[0] has summary, no outer[1] for topics
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[["A summary"]],
[[["A summary"]]],
)
httpx_mock.add_response(content=response.encode())
@ -595,11 +601,11 @@ class TestDescribeEdgeCases:
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Line 173->177: result[1] exists but is an empty list, so inner block skipped."""
# result = [["A summary"], []] — result[1] has len 0, so the inner if is false
"""outer[1] exists but is an empty list, so topics block is skipped."""
# result = [[["A summary"], []]] — outer[1] is empty, so topics are skipped
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[["A summary"], []],
[[["A summary"], []]],
)
httpx_mock.add_response(content=response.encode())
@ -616,11 +622,11 @@ class TestDescribeEdgeCases:
httpx_mock: HTTPXMock,
build_rpc_response,
):
"""Line 173->177: result[1] is present but not a list, inner block skipped."""
# result = [["A summary"], "not-a-list"]
"""outer[1] is present but not a list, so topics block is skipped."""
# result = [[["A summary"], "not-a-list"]] — outer[1] is not a list, topics skipped
response = build_rpc_response(
RPCMethod.SUMMARIZE,
[["A summary"], "not-a-list"],
[[["A summary"], "not-a-list"]],
)
httpx_mock.add_response(content=response.encode())

View file

@ -254,13 +254,15 @@ class TestGetNotebookDescription:
async def test_get_notebook_description_parses_response(self, mock_client):
"""Test get_notebook_description parses full response."""
mock_response = [
["This notebook explores **AI** and **machine learning**."],
[
["This notebook explores **AI** and **machine learning**."],
[
["What is the future of AI?", "Create a detailed briefing..."],
["How does ML work?", "Explain the fundamentals..."],
]
],
[
["What is the future of AI?", "Create a detailed briefing..."],
["How does ML work?", "Explain the fundamentals..."],
]
],
]
]
mock_client._core.rpc_call = AsyncMock(return_value=mock_response)