mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 10:15:47 +00:00
* feat: add composer input polishing * Revert "Merge branch 'main' into feat/input-polish" This reverts commit5b6ceccf0d, reversing changes made to45fbc57fef. * 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>
196 lines
6.7 KiB
Python
196 lines
6.7 KiB
Python
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from app.gateway.routers import input_polish
|
|
from deerflow.utils import oneshot_llm
|
|
|
|
|
|
def _config(
|
|
*,
|
|
enabled: bool = True,
|
|
max_chars: int = 4000,
|
|
model_name: str | None = None,
|
|
):
|
|
return SimpleNamespace(
|
|
input_polish=SimpleNamespace(
|
|
enabled=enabled,
|
|
max_chars=max_chars,
|
|
model_name=model_name,
|
|
),
|
|
)
|
|
|
|
|
|
def test_clean_rewritten_text_removes_think_and_fence():
|
|
text = "<think>reasoning</think>\n```text\nrewrite this\n```"
|
|
assert input_polish._clean_rewritten_text(text) == "rewrite this"
|
|
|
|
|
|
def test_clean_rewritten_text_keeps_literal_think_tag():
|
|
# A polished draft may legitimately mention the <think> tag. The cleaner
|
|
# must not truncate at the dangling open tag (which would drop the rest of
|
|
# the rewrite and can surface as a spurious 503).
|
|
text = "Explain what the <think> tag does in reasoning models."
|
|
assert input_polish._clean_rewritten_text(text) == "Explain what the <think> tag does in reasoning models."
|
|
|
|
|
|
def test_polish_input_uses_config_model_and_preserves_response(monkeypatch):
|
|
request = input_polish.InputPolishRequest(
|
|
text="/web-dev 做一个页面",
|
|
locale="zh-CN",
|
|
thread_id="thread-1",
|
|
)
|
|
fake_model = MagicMock()
|
|
fake_model.ainvoke = AsyncMock(return_value=MagicMock(content="/web-dev 请设计并实现一个视觉精致的页面。"))
|
|
|
|
create_chat_model = MagicMock(return_value=fake_model)
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", create_chat_model)
|
|
config = _config(model_name="polish-model")
|
|
|
|
result = asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
request,
|
|
request=None,
|
|
config=config,
|
|
),
|
|
)
|
|
|
|
assert result.rewritten_text == "/web-dev 请设计并实现一个视觉精致的页面。"
|
|
assert result.changed is True
|
|
create_chat_model.assert_called_once_with(
|
|
name="polish-model",
|
|
thinking_enabled=False,
|
|
app_config=config,
|
|
)
|
|
fake_model.ainvoke.assert_awaited_once()
|
|
assert fake_model.ainvoke.await_args.kwargs["config"]["run_name"] == "input_polish"
|
|
|
|
|
|
def test_polish_input_uses_default_model_when_config_model_is_missing(monkeypatch):
|
|
request = input_polish.InputPolishRequest(text="make this clearer")
|
|
fake_model = MagicMock()
|
|
fake_model.ainvoke = AsyncMock(return_value=MagicMock(content="Make this clearer."))
|
|
|
|
create_chat_model = MagicMock(return_value=fake_model)
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", create_chat_model)
|
|
|
|
result = asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
request,
|
|
request=None,
|
|
config=_config(model_name=None),
|
|
),
|
|
)
|
|
|
|
assert result.rewritten_text == "Make this clearer."
|
|
create_chat_model.assert_called_once()
|
|
assert create_chat_model.call_args.kwargs["name"] is None
|
|
|
|
|
|
def test_polish_input_returns_404_when_disabled(monkeypatch):
|
|
request = input_polish.InputPolishRequest(text="hello")
|
|
fake_model = MagicMock()
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", fake_model)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
request,
|
|
request=None,
|
|
config=_config(enabled=False),
|
|
),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
fake_model.assert_not_called()
|
|
|
|
|
|
def test_polish_input_rejects_empty_or_too_long_input(monkeypatch):
|
|
fake_model = MagicMock()
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", fake_model)
|
|
|
|
with pytest.raises(HTTPException) as empty_exc:
|
|
asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
input_polish.InputPolishRequest(text=" "),
|
|
request=None,
|
|
config=_config(),
|
|
),
|
|
)
|
|
assert empty_exc.value.status_code == 400
|
|
|
|
with pytest.raises(HTTPException) as long_exc:
|
|
asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
input_polish.InputPolishRequest(text="hello"),
|
|
request=None,
|
|
config=_config(max_chars=4),
|
|
),
|
|
)
|
|
assert long_exc.value.status_code == 400
|
|
fake_model.assert_not_called()
|
|
|
|
|
|
def test_polish_input_returns_503_on_model_error(monkeypatch):
|
|
request = input_polish.InputPolishRequest(text="hello")
|
|
fake_model = MagicMock()
|
|
fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("boom"))
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", MagicMock(return_value=fake_model))
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
request,
|
|
request=None,
|
|
config=_config(),
|
|
),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 503
|
|
|
|
|
|
def test_polish_input_rejects_whitespace_only_draft(monkeypatch):
|
|
# A padded draft that is empty after normalization is rejected as empty,
|
|
# matching the normalized view used for the model input.
|
|
fake_model = MagicMock()
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", fake_model)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
input_polish.InputPolishRequest(text=" \n\t "),
|
|
request=None,
|
|
config=_config(),
|
|
),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
fake_model.assert_not_called()
|
|
|
|
|
|
def test_polish_input_validates_and_sends_normalized_text(monkeypatch):
|
|
# The length boundary and the model input must agree on one normalized view:
|
|
# a draft whose raw length exceeds max_chars only due to padding is accepted
|
|
# (strip fits), and the model receives the stripped text, not the padding.
|
|
raw_draft = " summarize report " # 22 chars raw, 16 chars stripped
|
|
fake_model = MagicMock()
|
|
fake_model.ainvoke = AsyncMock(return_value=MagicMock(content="Please summarize the report clearly."))
|
|
monkeypatch.setattr(oneshot_llm, "create_chat_model", MagicMock(return_value=fake_model))
|
|
|
|
result = asyncio.run(
|
|
input_polish.polish_input.__wrapped__(
|
|
input_polish.InputPolishRequest(text=raw_draft),
|
|
request=None,
|
|
config=_config(max_chars=len(raw_draft.strip())),
|
|
),
|
|
)
|
|
|
|
assert result.rewritten_text == "Please summarize the report clearly."
|
|
messages = fake_model.ainvoke.await_args.args[0]
|
|
human_content = messages[-1].content
|
|
assert "summarize report" in human_content
|
|
assert " summarize report " not in human_content
|