deer-flow/backend/app/gateway/routers/input_polish.py
Ryker_Feng 01dc067997
feat: add composer input polishing (#3986)
* 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>
2026-07-08 17:10:27 +08:00

108 lines
4.7 KiB
Python

import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
import deerflow.utils.llm_text as llm_text
from app.gateway.authz import require_permission
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.utils.oneshot_llm import run_oneshot_llm
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["input-polish"])
class InputPolishRequest(BaseModel):
text: str = Field(..., description="Draft text currently shown in the composer")
locale: str | None = Field(default=None, description="Optional UI locale hint")
thread_id: str | None = Field(default=None, description="Optional thread id for tracing only")
class InputPolishResponse(BaseModel):
rewritten_text: str = Field(..., description="Polished draft text")
changed: bool = Field(..., description="Whether the model changed the original draft")
def _clean_rewritten_text(text: str) -> str:
# The polished draft may legitimately contain a literal "<think>" substring
# (e.g. a draft that asks about the tag), so do NOT truncate at a dangling
# open tag here — that would silently drop the rest of a valid rewrite and
# can produce a spurious 503. Complete <think>...</think> blocks are still
# removed.
candidate = llm_text.strip_think_blocks(text, truncate_unclosed=False)
candidate = llm_text.strip_markdown_code_fence(candidate)
return candidate.strip()
def _build_system_instruction() -> str:
return (
"You are DeerFlow's pre-send prompt optimizer.\n"
"Rewrite the user's rough draft into a clearer instruction for an AI agent before it is sent.\n"
"Do not answer the task.\n"
"Preserve the user's language, intent, entities, file paths, URLs, code blocks, and any leading slash command prefix exactly.\n"
"Improve the draft by making the goal, scope, constraints, and desired output explicit when they are implied by the draft.\n"
"For vague quality words such as 'better', 'good-looking', or 'polished', translate them into concrete but generic quality criteria.\n"
"Do not invent facts, business context, tools, file names, dates, metrics, or user preferences that are not implied.\n"
"Prefer one concise paragraph or a short bullet list. Keep it under 180 words unless the original draft is longer.\n"
"Output only the rewritten draft, with no markdown wrapper, explanation, or alternatives."
)
def _build_user_content(text: str, locale: str | None) -> str:
locale_hint = locale.strip() if locale else "same language as the draft"
return f"Locale hint: {locale_hint}\n\nRewrite this draft while preserving its intent:\n<draft>\n{text}\n</draft>"
@router.post(
"/input-polish",
response_model=InputPolishResponse,
summary="Polish Composer Input",
description="Rewrite a draft message before it is sent. This does not create a thread run or persist any message.",
)
@require_permission("runs", "create")
async def polish_input(
body: InputPolishRequest,
request: Request,
config: AppConfig = Depends(get_config),
) -> InputPolishResponse:
del request # Required by the auth decorator.
if not config.input_polish.enabled:
raise HTTPException(status_code=404, detail="Input polishing is disabled")
# Validate the same normalized view of the input that we send to the model,
# so the user-facing length boundary and the model input cannot disagree
# (e.g. a padded draft passing the check but arriving with stray whitespace).
text = body.text.strip()
if not text:
raise HTTPException(status_code=400, detail="Input text is required")
max_chars = config.input_polish.max_chars
if len(text) > max_chars:
raise HTTPException(status_code=400, detail=f"Input text exceeds {max_chars} characters")
model_name = config.input_polish.model_name
try:
raw = await run_oneshot_llm(
system_instruction=_build_system_instruction(),
user_content=_build_user_content(text, body.locale),
run_name="input_polish",
app_config=config,
model_name=model_name,
thread_id=body.thread_id,
)
rewritten = _clean_rewritten_text(raw)
except Exception as exc:
logger.exception("Failed to polish input: thread_id=%s err=%s", body.thread_id, exc)
raise HTTPException(status_code=503, detail="Failed to polish input") from exc
if not rewritten:
raise HTTPException(status_code=503, detail="Failed to polish input")
return InputPolishResponse(
rewritten_text=rewritten,
changed=rewritten != text,
)