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:
parent
b1d7a18ce8
commit
1a6fe4723b
4 changed files with 34 additions and 22 deletions
|
|
@ -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)}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue