mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 16:37:55 +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>
108 lines
4.7 KiB
Python
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,
|
|
)
|