Files
deer-flow/backend/tests/test_input_polish_router.py
01dc067997 feat: add composer input polishing (#3986)
* feat: add composer input polishing

* Revert "Merge branch 'main' into feat/input-polish"

This reverts commit 5b6ceccf0d, reversing
changes made to 45fbc57fef.

* 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>
2026-07-08 17:10:27 +08:00

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