LLMResult.from_provider теряет finish_reason — content_filter/length-обрезка возвращаются как ok=True #1602

Closed
opened 2026-06-16 17:16:28 +00:00 by bot-backend · 0 comments
Collaborator

Severity: low · Категория: logic/silent-data-loss · Файл: backend/app/services/llm/client.py:78-87

client.py:79-87 — LLMResult.from_provider копирует content/tool_calls/prompt_tokens/completion_tokens/model из ProviderResponse, но НЕ переносит resp.finish_reason (поле есть в ProviderResponse, provider.py:59, и парсится в _parse_openai_response, provider.py:116). Любой ответ с HTTP 200 становится LLMResult(ok=True) независимо от finish_reason: 'content_filter' (модерация OpenAI зарезала ответ), 'length' (обрезка по max_output_tokens) или пустой content при пустых tool_calls. Контракт для консьюмеров (init.py п.1): детерминированный fallback подставляется ТОЛЬКО при ok=False. Импакт: обрезанный/отфильтрованный ответ приходит с ok=True и частичным/пустым content; консьюмер, не дублирующий проверку пустоты, покажет полу-ответ как «валидный заземлённый результат» (ложь в отчёте). Поскольку finish_reason выброшен из LLMResult, ни один консьюмер физически не может отличить обрезку/фильтрацию от нормального завершения. Снижено до low: пакет foundational, реальных консьюмеров пока нет (init.py:3-4 — #956/#957 приедут позже), это латентный риск под будущих консьюмеров. Фикс: добавить поле finish_reason в LLMResult и пробросить его в from_provider; опционально — помечать ok=False/добавлять флаг truncated при finish_reason in {'content_filter','length'}.

Почему баг: Контракт для консьюмеров (init.py п.1): подставлять детерминированный fallback ТОЛЬКО при ok=False. Ответ, обрезанный модерацией/лимитом длины, возвращается с ok=True и частичным/пустым content. Консьюмер, который не дублирует проверку пустоты (в отличие от chat-orchestrator, который её делает на строке 168-169), получит обрезанный/отфильтрованный текст как «валидный LLM-результат» и покажет его клиенту как заземлённый ответ — потенциальная «ложь в отчёте» (полу-ответ выглядит как полный). Поскольку finish_reason выброшен, ни один консьюмер физически НЕ может отличить обрезку/фильтрацию от нормального завершения.

Как чинить: Пробросить finish_reason в LLMResult и/или в client трактовать finish_reason in {'content_filter'} (а для extraction-консьюмеров и 'length') как сигнал к fallback (reason='content_filter'/'truncated'), а не ok=True. Минимум — сохранить finish_reason в LLMResult, чтобы консьюмер мог принять решение.


deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.7 · unit B29

**Severity:** low · **Категория:** logic/silent-data-loss · **Файл:** `backend/app/services/llm/client.py:78-87` client.py:79-87 — LLMResult.from_provider копирует content/tool_calls/prompt_tokens/completion_tokens/model из ProviderResponse, но НЕ переносит resp.finish_reason (поле есть в ProviderResponse, provider.py:59, и парсится в _parse_openai_response, provider.py:116). Любой ответ с HTTP 200 становится LLMResult(ok=True) независимо от finish_reason: 'content_filter' (модерация OpenAI зарезала ответ), 'length' (обрезка по max_output_tokens) или пустой content при пустых tool_calls. Контракт для консьюмеров (__init__.py п.1): детерминированный fallback подставляется ТОЛЬКО при ok=False. Импакт: обрезанный/отфильтрованный ответ приходит с ok=True и частичным/пустым content; консьюмер, не дублирующий проверку пустоты, покажет полу-ответ как «валидный заземлённый результат» (ложь в отчёте). Поскольку finish_reason выброшен из LLMResult, ни один консьюмер физически не может отличить обрезку/фильтрацию от нормального завершения. Снижено до low: пакет foundational, реальных консьюмеров пока нет (__init__.py:3-4 — #956/#957 приедут позже), это латентный риск под будущих консьюмеров. Фикс: добавить поле finish_reason в LLMResult и пробросить его в from_provider; опционально — помечать ok=False/добавлять флаг truncated при finish_reason in {'content_filter','length'}. **Почему баг:** Контракт для консьюмеров (__init__.py п.1): подставлять детерминированный fallback ТОЛЬКО при ok=False. Ответ, обрезанный модерацией/лимитом длины, возвращается с ok=True и частичным/пустым content. Консьюмер, который не дублирует проверку пустоты (в отличие от chat-orchestrator, который её делает на строке 168-169), получит обрезанный/отфильтрованный текст как «валидный LLM-результат» и покажет его клиенту как заземлённый ответ — потенциальная «ложь в отчёте» (полу-ответ выглядит как полный). Поскольку finish_reason выброшен, ни один консьюмер физически НЕ может отличить обрезку/фильтрацию от нормального завершения. **Как чинить:** Пробросить finish_reason в LLMResult и/или в client трактовать finish_reason in {'content_filter'} (а для extraction-консьюмеров и 'length') как сигнал к fallback (reason='content_filter'/'truncated'), а не ok=True. Минимум — сохранить finish_reason в LLMResult, чтобы консьюмер мог принять решение. --- <sub>deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.7 · unit B29</sub>
bot-backend added the
week ревью 1
label 2026-06-16 17:16:28 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1602
No description provided.