fix: handle structured content format in LLM response parsing

Some LLM providers (notably Gemini, DeepSeek via OpenAI-compatible
proxies) return ai_message.content as a list of content parts:
[{'type': 'text', 'text': '...', 'extras': {...}}]

The current code uses str() on non-string content, which produces the
Python repr of the entire list — not valid JSON. This breaks
PydanticOutputParser.parse() with OutputParserException.

This adds extract_text_content() to properly unwrap text from both
string and structured content formats, applied in ask.py, chat.py,
and prompt.py.

Fixes #329
This commit is contained in:
danrush777 2026-02-08 22:29:45 +01:00
parent b1d7a18ce8
commit 1a6fe4723b
4 changed files with 34 additions and 22 deletions

View file

@ -12,6 +12,7 @@ from typing_extensions import TypedDict
from open_notebook.ai.provision import provision_langchain_model
from open_notebook.domain.notebook import vector_search
from open_notebook.utils import clean_thinking_content
from open_notebook.utils.text_utils import extract_text_content
class SubGraphState(TypedDict):
@ -62,11 +63,7 @@ async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -
ai_message = await model.ainvoke(system_prompt)
# Clean the thinking content from the response
message_content = (
ai_message.content
if isinstance(ai_message.content, str)
else str(ai_message.content)
)
message_content = extract_text_content(ai_message.content)
cleaned_content = clean_thinking_content(message_content)
# Parse the cleaned JSON content
@ -109,11 +106,7 @@ async def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict:
max_tokens=2000,
)
ai_message = await model.ainvoke(system_prompt)
ai_content = (
ai_message.content
if isinstance(ai_message.content, str)
else str(ai_message.content)
)
ai_content = extract_text_content(ai_message.content)
return {"answers": [clean_thinking_content(ai_content)]}
@ -126,11 +119,7 @@ async def write_final_answer(state: ThreadState, config: RunnableConfig) -> dict
max_tokens=2000,
)
ai_message = await model.ainvoke(system_prompt)
final_content = (
ai_message.content
if isinstance(ai_message.content, str)
else str(ai_message.content)
)
final_content = extract_text_content(ai_message.content)
return {"final_answer": clean_thinking_content(final_content)}

View file

@ -14,6 +14,7 @@ from open_notebook.ai.provision import provision_langchain_model
from open_notebook.config import LANGGRAPH_CHECKPOINT_FILE
from open_notebook.domain.notebook import Notebook
from open_notebook.utils import clean_thinking_content
from open_notebook.utils.text_utils import extract_text_content
class ThreadState(TypedDict):
@ -69,11 +70,7 @@ def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict
ai_message = model.invoke(payload)
# Clean thinking content from AI response (e.g., <think>...</think> tags)
content = (
ai_message.content
if isinstance(ai_message.content, str)
else str(ai_message.content)
)
content = extract_text_content(ai_message.content)
cleaned_content = clean_thinking_content(content)
cleaned_message = ai_message.model_copy(update={"content": cleaned_content})

View file

@ -7,7 +7,7 @@ from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict
from open_notebook.ai.provision import provision_langchain_model
from open_notebook.utils.text_utils import clean_thinking_content
from open_notebook.utils.text_utils import clean_thinking_content, extract_text_content
class PatternChainState(TypedDict):
@ -33,7 +33,7 @@ async def call_model(state: dict, config: RunnableConfig) -> dict:
response = await chain.ainvoke(payload)
# Clean thinking tags from response (handles extended thinking models)
output = clean_thinking_content(str(response.content))
output = clean_thinking_content(extract_text_content(response.content))
return {"output": output}

View file

@ -117,3 +117,29 @@ def clean_thinking_content(content: str) -> str:
"""
_, cleaned_content = parse_thinking_content(content)
return cleaned_content
def extract_text_content(content) -> str:
"""Extract text from LLM response content.
Handles both plain string responses and structured content formats
(e.g. Gemini's envelope format):
[{'type': 'text', 'text': '...', 'extras': {...}}]
Args:
content: The content from an AI message, either a string or a list of parts.
Returns:
The extracted text content as a string.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for part in content:
if isinstance(part, dict) and "text" in part:
text_parts.append(part["text"])
elif isinstance(part, str):
text_parts.append(part)
return "".join(text_parts)
return str(content)