diff --git a/application/api/answer/services/conversation_service.py b/application/api/answer/services/conversation_service.py index f116a274..97f6d54b 100644 --- a/application/api/answer/services/conversation_service.py +++ b/application/api/answer/services/conversation_service.py @@ -33,6 +33,25 @@ TERMINATED_RESPONSE_PLACEHOLDER = ( ) +def _final_finish_reason(llm: Any) -> Optional[str]: + """Best-effort finish_reason of the round that actually produced the answer. + + Read from the responding provider — the fallback instance when a fallback + served the stream, else the primary. Set by the Responses path, the + non-streaming path, and (now) the chat-completions stream. ``None`` when + unavailable (e.g. the stream was cut before any finish frame). + """ + if llm is None: + return None + fallback = getattr(llm, "_fallback_llm", None) + responding = getattr(llm, "_responding_provider", None) + if fallback is not None and responding == getattr(fallback, "provider_name", None): + fr = getattr(fallback, "_last_finish_reason", None) + if fr is not None: + return fr + return getattr(llm, "_last_finish_reason", None) + + class ConversationService: def get_conversation( self, conversation_id: str, user_id: str @@ -112,6 +131,25 @@ class ConversationService: if metadata: message_payload["metadata"] = metadata + # Classify empty answers at the point of persistence. A tool-request + # turn (finish_reason=tool_calls — the /v1 client-tool loop) and a + # genuine dead-end (finish_reason=stop with no visible content) both + # persist an empty ``response`` and are otherwise indistinguishable in + # the stored row; log the discriminators so they can be told apart. + if not (response or "").strip(): + logger.info( + "empty_answer_persisted", + extra={ + "model": model_id, + "finish_reason": _final_finish_reason(llm), + "has_tool_calls": bool(tool_calls), + "tool_call_count": len(tool_calls or []), + "thought_len": len(thought or ""), + "responding_provider": getattr(llm, "_responding_provider", None), + "conversation_id": conversation_id, + }, + ) + if conversation_id is not None and index is not None: with db_session() as conn: repo = ConversationsRepository(conn) diff --git a/application/llm/openai.py b/application/llm/openai.py index e078a839..8f62f1a5 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -536,6 +536,7 @@ class OpenAILLM(BaseLLM): request_params["stream_options"] = stream_options self._last_usage = None self._stream_reached_finish = False + self._last_finish_reason = None response = self.client.chat.completions.create(**request_params) try: @@ -564,6 +565,11 @@ class OpenAILLM(BaseLLM): # this to refuse restreaming a delivered answer when a # trailing frame fails. self._stream_reached_finish = True + # Capture the reason itself (not just the bool) so the save + # path can classify an empty answer: a ``stop`` with no + # content is a genuine dead-end, ``tool_calls`` is a tool + # request. Previously the chat path discarded this. + self._last_finish_reason = finish_reason # Yield non-content chunks only when needed for tool-call handling. if has_tool_calls or finish_reason == "tool_calls":