mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
* feat: add composer input polishing * Revert "Merge branch 'main' into feat/input-polish" This reverts commit 5b6ceccf0db3092bc62fde3b05e7816829601756, reversing changes made to 45fbc57fef5fa5fd878cf0176c37f3e3bc7ebef6. * Merge main into feat/input-polish * style(frontend): format input helper polish guard * fix(input-polish): address composer polish review findings Frontend - Add a cancel affordance to the in-flight polish status pill that calls abortInputPolishRequest(), so a slow/hung provider no longer hard-locks the composer for up to stream_chunk_timeout with a page reload (and draft loss) as the only escape. - Reset promptHistoryIndexRef/promptHistoryDraftRef when a rewrite is applied (and on undo), so a stale history-browse index can no longer let the next ArrowDown silently overwrite the polished draft. - Disable polishing while an open human-input card is present, matching the frontend/AGENTS.md rule that composer entry points defer to the card so card-reply metadata is preserved. - canPolishInput now reuses parseGoalCommand/parseCompactCommand instead of a third hardcoded reserved-command regex, and drops the phantom /help entry (no /help parser exists in the composer), so future builtins only need to be taught to the existing parsers. Backend - Extract the non-graph one-shot LLM path (build model + inject Langfuse metadata + system/user invoke + text extract) into deerflow.utils.oneshot_llm.run_oneshot_llm, shared by the input-polish and suggestions routers so tracing-metadata and invocation shape cannot drift between the two copies. - strip_think_blocks gains truncate_unclosed (default True, preserving the suggestions/goal JSON-prep behavior); input polish passes False so a draft that legitimately contains a literal <think> substring is no longer truncated into a partial rewrite or a spurious 503. - Validate the empty-check and max_chars boundary against the same stripped view of the draft that is sent to the model, so the user-facing length boundary and the model input can no longer disagree. Tests / docs - Backend: literal-<think> preservation, whitespace-only rejection, and normalized-length/model-input agreement cases; suggestions tests repoint the create_chat_model patch to the shared helper module. - Frontend: helper unit tests updated for the /help/reserved-command change; a new Playwright case covers cancelling an in-flight polish request. - backend/AGENTS.md documents the shared one-shot helper and the polish normalization/think-tag behavior. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
61 lines
2.4 KiB
Python
61 lines
2.4 KiB
Python
"""Utilities for normalizing LLM response text before structured parsing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
# Matches a complete <think>...</think> block (case-insensitive, spans newlines).
|
|
_THINK_BLOCK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.IGNORECASE | re.DOTALL)
|
|
# Matches a dangling, unclosed <think> (model truncated at max_tokens mid-thought).
|
|
_OPEN_THINK_RE = re.compile(r"<think\b[^>]*>", re.IGNORECASE)
|
|
|
|
|
|
def strip_think_blocks(text: str, *, truncate_unclosed: bool = True) -> str:
|
|
"""Remove inline reasoning ``<think>`` blocks from a model response.
|
|
|
|
Complete ``<think>...</think>`` blocks are always removed. A dangling,
|
|
unclosed ``<think>`` open tag is treated as a model that was truncated
|
|
mid-thought: when ``truncate_unclosed`` is True (the default, used by JSON
|
|
parsers like suggestions/goal where trailing garbage must be dropped) the
|
|
text is cut at that tag. Callers that may legitimately echo a literal
|
|
``<think>`` substring in their output (e.g. the input polisher rewriting a
|
|
draft that mentions the tag) pass ``truncate_unclosed=False`` so the tag is
|
|
preserved instead of silently discarding the rest of the text.
|
|
"""
|
|
text = _THINK_BLOCK_RE.sub("", text)
|
|
if truncate_unclosed:
|
|
open_match = _OPEN_THINK_RE.search(text)
|
|
if open_match:
|
|
text = text[: open_match.start()]
|
|
return text.strip()
|
|
|
|
|
|
def strip_markdown_code_fence(text: str) -> str:
|
|
"""Remove a single wrapping markdown code fence when present."""
|
|
stripped = text.strip()
|
|
if not stripped.startswith("```"):
|
|
return stripped
|
|
lines = stripped.splitlines()
|
|
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
|
|
return "\n".join(lines[1:-1]).strip()
|
|
return stripped
|
|
|
|
|
|
def extract_response_text(content: object) -> str:
|
|
"""Extract textual content from common chat-model response content shapes."""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if isinstance(block, str):
|
|
parts.append(block)
|
|
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
|
|
text = block.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
return "\n".join(parts)
|
|
if content is None:
|
|
return ""
|
|
return str(content)
|