feat(suggestions): configure follow-up suggestion count (#4533)

This commit is contained in:
Ryker_Feng 2026-07-28 19:35:21 +08:00 committed by GitHub
parent 919caf7c83
commit 0a9ce5d7e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 57 additions and 7 deletions

View File

@ -8,6 +8,7 @@ import deerflow.utils.llm_text as llm_text
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.deps import get_config from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
from deerflow.config.suggestions_config import DEFAULT_MAX_SUGGESTIONS, MAX_SUGGESTIONS_LIMIT
from deerflow.utils.oneshot_llm import run_oneshot_llm from deerflow.utils.oneshot_llm import run_oneshot_llm
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -22,7 +23,7 @@ class SuggestionMessage(BaseModel):
class SuggestionsRequest(BaseModel): class SuggestionsRequest(BaseModel):
messages: list[SuggestionMessage] = Field(..., description="Recent conversation messages") messages: list[SuggestionMessage] = Field(..., description="Recent conversation messages")
n: int = Field(default=3, ge=1, le=5, description="Number of suggestions to generate") n: int = Field(default=DEFAULT_MAX_SUGGESTIONS, ge=1, le=MAX_SUGGESTIONS_LIMIT, description="Number of suggestions to generate")
model_name: str | None = Field(default=None, description="Optional model override") model_name: str | None = Field(default=None, description="Optional model override")
@ -32,6 +33,7 @@ class SuggestionsResponse(BaseModel):
class SuggestionsConfigResponse(BaseModel): class SuggestionsConfigResponse(BaseModel):
enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally") enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally")
max_suggestions: int = Field(..., ge=1, le=MAX_SUGGESTIONS_LIMIT, description="Maximum number of follow-up suggestions to generate")
_strip_markdown_code_fence = llm_text.strip_markdown_code_fence _strip_markdown_code_fence = llm_text.strip_markdown_code_fence
@ -76,6 +78,10 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
return "\n".join(parts).strip() return "\n".join(parts).strip()
def _configured_max_suggestions(config: AppConfig) -> int:
return getattr(config.suggestions, "max_suggestions", DEFAULT_MAX_SUGGESTIONS)
@router.get( @router.get(
"/suggestions/config", "/suggestions/config",
response_model=SuggestionsConfigResponse, response_model=SuggestionsConfigResponse,
@ -85,7 +91,7 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str:
async def get_suggestions_config( async def get_suggestions_config(
config: AppConfig = Depends(get_config), config: AppConfig = Depends(get_config),
) -> SuggestionsConfigResponse: ) -> SuggestionsConfigResponse:
return SuggestionsConfigResponse(enabled=config.suggestions.enabled) return SuggestionsConfigResponse(enabled=config.suggestions.enabled, max_suggestions=_configured_max_suggestions(config))
@router.post( @router.post(
@ -106,7 +112,7 @@ async def generate_suggestions(
if not body.messages: if not body.messages:
return SuggestionsResponse(suggestions=[]) return SuggestionsResponse(suggestions=[])
n = body.n n = min(body.n, _configured_max_suggestions(config))
conversation = _format_conversation(body.messages) conversation = _format_conversation(body.messages)
if not conversation: if not conversation:
return SuggestionsResponse(suggestions=[]) return SuggestionsResponse(suggestions=[])

View File

@ -1,7 +1,16 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
DEFAULT_MAX_SUGGESTIONS = 3
MAX_SUGGESTIONS_LIMIT = 5
class SuggestionsConfig(BaseModel): class SuggestionsConfig(BaseModel):
"""Configuration for automatic follow-up suggestions.""" """Configuration for automatic follow-up suggestions."""
enabled: bool = Field(default=True, description="Whether to enable follow-up question suggestions at the end of an AI response") enabled: bool = Field(default=True, description="Whether to enable follow-up question suggestions at the end of an AI response")
max_suggestions: int = Field(
default=DEFAULT_MAX_SUGGESTIONS,
ge=1,
le=MAX_SUGGESTIONS_LIMIT,
description="Maximum number of follow-up suggestions to generate.",
)

View File

@ -125,6 +125,31 @@ def test_generate_suggestions_parses_and_limits(monkeypatch):
assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"} assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"}
def test_generate_suggestions_respects_configured_max(monkeypatch):
req = suggestions.SuggestionsRequest(
messages=[
suggestions.SuggestionMessage(role="user", content="Hi"),
suggestions.SuggestionMessage(role="assistant", content="Hello"),
],
n=4,
model_name=None,
)
fake_model = MagicMock()
fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='["Q1", "Q2", "Q3", "Q4"]'))
monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model)
result = asyncio.run(
suggestions.generate_suggestions.__wrapped__(
"t1",
req,
request=None,
config=SimpleNamespace(suggestions=SimpleNamespace(enabled=True, max_suggestions=2)),
)
)
assert result.suggestions == ["Q1", "Q2"]
def test_generate_suggestions_injects_deerflow_trace_metadata_when_langfuse_enabled(monkeypatch): def test_generate_suggestions_injects_deerflow_trace_metadata_when_langfuse_enabled(monkeypatch):
monkeypatch.setenv("LANGFUSE_TRACING", "true") monkeypatch.setenv("LANGFUSE_TRACING", "true")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test") monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
@ -248,8 +273,10 @@ def test_get_suggestions_config():
mock_config_true = SimpleNamespace(suggestions=SimpleNamespace(enabled=True)) mock_config_true = SimpleNamespace(suggestions=SimpleNamespace(enabled=True))
result_true = asyncio.run(suggestions.get_suggestions_config(config=mock_config_true)) result_true = asyncio.run(suggestions.get_suggestions_config(config=mock_config_true))
assert result_true.enabled is True assert result_true.enabled is True
assert result_true.max_suggestions == 3
# Test when disabled # Test when disabled
mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False)) mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False, max_suggestions=4))
result_false = asyncio.run(suggestions.get_suggestions_config(config=mock_config_false)) result_false = asyncio.run(suggestions.get_suggestions_config(config=mock_config_false))
assert result_false.enabled is False assert result_false.enabled is False
assert result_false.max_suggestions == 4

View File

@ -1050,6 +1050,7 @@ tool_output:
suggestions: suggestions:
enabled: true enabled: true
max_suggestions: 3
# ============================================================================ # ============================================================================

View File

@ -81,6 +81,7 @@ import {
type SidecarContext, type SidecarContext,
} from "@/core/sidecar"; } from "@/core/sidecar";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { DEFAULT_MAX_SUGGESTIONS } from "@/core/suggestions/api";
import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import { useSuggestionsConfig } from "@/core/suggestions/hooks";
import type { AgentThreadContext, GoalState } from "@/core/threads"; import type { AgentThreadContext, GoalState } from "@/core/threads";
import { compactThreadContext } from "@/core/threads/api"; import { compactThreadContext } from "@/core/threads/api";
@ -398,6 +399,8 @@ export function InputBox({
const { data: suggestionsConfig } = useSuggestionsConfig(); const { data: suggestionsConfig } = useSuggestionsConfig();
const suggestionsConfigLoaded = suggestionsConfig !== undefined; const suggestionsConfigLoaded = suggestionsConfig !== undefined;
const suggestionsEnabled = suggestionsConfig?.enabled; const suggestionsEnabled = suggestionsConfig?.enabled;
const maxFollowupSuggestions =
suggestionsConfig?.max_suggestions ?? DEFAULT_MAX_SUGGESTIONS;
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
const [followupsLoading, setFollowupsLoading] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false);
const [polishingInput, setPolishingInput] = useState(false); const [polishingInput, setPolishingInput] = useState(false);
@ -1998,7 +2001,7 @@ export function InputBox({
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
messages: recent, messages: recent,
n: 3, n: maxFollowupSuggestions,
model_name: context.model_name ?? undefined, model_name: context.model_name ?? undefined,
}), }),
signal: controller.signal, signal: controller.signal,
@ -2013,7 +2016,7 @@ export function InputBox({
const suggestions = (data.suggestions ?? []) const suggestions = (data.suggestions ?? [])
.map((s) => (typeof s === "string" ? s.trim() : "")) .map((s) => (typeof s === "string" ? s.trim() : ""))
.filter((s) => s.length > 0) .filter((s) => s.length > 0)
.slice(0, 5); .slice(0, maxFollowupSuggestions);
setFollowups(suggestions); setFollowups(suggestions);
}) })
.catch(() => { .catch(() => {
@ -2028,6 +2031,7 @@ export function InputBox({
context.model_name, context.model_name,
disabled, disabled,
isMock, isMock,
maxFollowupSuggestions,
status, status,
suggestionsConfigLoaded, suggestionsConfigLoaded,
suggestionsEnabled, suggestionsEnabled,

View File

@ -1,8 +1,11 @@
import { fetch } from "@/core/api/fetcher"; import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
export const DEFAULT_MAX_SUGGESTIONS = 3;
export interface SuggestionsConfigResponse { export interface SuggestionsConfigResponse {
enabled: boolean; enabled: boolean;
max_suggestions: number;
} }
export async function loadSuggestionsConfig(): Promise<SuggestionsConfigResponse> { export async function loadSuggestionsConfig(): Promise<SuggestionsConfigResponse> {
@ -10,7 +13,7 @@ export async function loadSuggestionsConfig(): Promise<SuggestionsConfigResponse
if (!response.ok) { if (!response.ok) {
if (response.status === 404) { if (response.status === 404) {
// Fallback to true if the backend is older // Fallback to true if the backend is older
return { enabled: true }; return { enabled: true, max_suggestions: DEFAULT_MAX_SUGGESTIONS };
} }
throw new Error( throw new Error(
`Failed to load suggestions config: ${response.statusText}`, `Failed to load suggestions config: ${response.statusText}`,