yetuge a41d0fcec1
fix(parsing): keep literal think tags inside JSON string values (#7907)
* fix(parsing): keep literal think tags inside JSON string values

Structured parsers ran strip_think_blocks over the whole response before
json.loads. A model answer that quotes the tag -- "Explain <think>example
</think>" -- lost the quoted text, and an unclosed literal tag inside a
string cut the JSON document mid-string, so suggestions returned None and
goal evaluation failed to parse.

Strip reasoning only outside JSON string literals: leading blocks are still
removed whole (a JSON example inside reasoning is never taken as the
answer), and blocks after the payload starts are removed only outside
quotes. Suggestions and the goal evaluator both use the shared helper.

* fix(parsing): keep think-tag stripping complete outside the JSON payload

The string-aware walker only covered the payload itself: a reasoning block
after prose and before the payload was copied verbatim, so brackets inside
it became the parsers' find/rfind targets, and an unclosed block in
trailing prose was kept where strip_think_blocks used to truncate.

Find the payload start while stepping over think blocks, strip the head with
strip_think_blocks, keep strip/truncate semantics after the top-level
payload closes, and truncate at an unclosed block outside a string. Adds a
CHANGELOG entry for the user-visible parsing change.
2026-11-06 21:19:52 +08:00

155 lines
6.1 KiB
Python

import json
import logging
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
import deerflow.utils.llm_text as llm_text
from app.gateway.authz import _is_internal_caller, authorize_model_use, require_permission
from app.gateway.deps import get_config, get_current_user_from_request
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.thread_id import ThreadId
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["suggestions"])
class SuggestionMessage(BaseModel):
role: str = Field(..., description="Message role: user|assistant")
content: str = Field(..., description="Message content as plain text")
class SuggestionsRequest(BaseModel):
messages: list[SuggestionMessage] = Field(..., description="Recent conversation messages")
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")
class SuggestionsResponse(BaseModel):
suggestions: list[str] = Field(default_factory=list, description="Suggested follow-up questions")
class SuggestionsConfigResponse(BaseModel):
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_think_blocks_outside_json_strings = llm_text.strip_think_blocks_outside_json_strings
def _parse_json_string_list(text: str) -> list[str] | None:
candidate = _strip_think_blocks_outside_json_strings(text)
candidate = _strip_markdown_code_fence(candidate)
start = candidate.find("[")
end = candidate.rfind("]")
if start == -1 or end == -1 or end <= start:
return None
candidate = candidate[start : end + 1]
try:
data = json.loads(candidate)
except Exception:
return None
if not isinstance(data, list):
return None
out: list[str] = []
for item in data:
if not isinstance(item, str):
continue
s = item.strip()
if not s:
continue
out.append(s)
return out
def _format_conversation(messages: list[SuggestionMessage]) -> str:
parts: list[str] = []
for m in messages:
role = m.role.strip().lower()
if role in ("user", "human"):
parts.append(f"User: {m.content.strip()}")
elif role in ("assistant", "ai"):
parts.append(f"Assistant: {m.content.strip()}")
else:
parts.append(f"{m.role}: {m.content.strip()}")
return "\n".join(parts).strip()
def _configured_max_suggestions(config: AppConfig) -> int:
return getattr(config.suggestions, "max_suggestions", DEFAULT_MAX_SUGGESTIONS)
@router.get(
"/suggestions/config",
response_model=SuggestionsConfigResponse,
summary="Get Suggestions Configuration",
description="Returns the global configuration for follow-up suggestions.",
)
async def get_suggestions_config(
config: AppConfig = Depends(get_config),
) -> SuggestionsConfigResponse:
return SuggestionsConfigResponse(enabled=config.suggestions.enabled, max_suggestions=_configured_max_suggestions(config))
@router.post(
"/threads/{thread_id}/suggestions",
response_model=SuggestionsResponse,
summary="Generate Follow-up Questions",
description="Generate short follow-up questions a user might ask next, based on recent conversation context.",
)
@require_permission("threads", "read", owner_check=True)
async def generate_suggestions(
thread_id: ThreadId,
body: SuggestionsRequest,
request: Request,
config: AppConfig = Depends(get_config),
) -> SuggestionsResponse:
if not config.suggestions.enabled:
return SuggestionsResponse(suggestions=[])
if not body.messages:
return SuggestionsResponse(suggestions=[])
n = min(body.n, _configured_max_suggestions(config))
conversation = _format_conversation(body.messages)
if not conversation:
return SuggestionsResponse(suggestions=[])
# Check the same effective model the factory will use, including its
# default when the caller omits model_name. Keep permission failures out
# of the best-effort LLM error handler below so a denial remains a 403.
user = await get_current_user_from_request(request)
authorize_model_use(user, body.model_name, is_internal=_is_internal_caller(request, user), app_config=config)
system_instruction = (
"You are generating follow-up questions to help the user continue the conversation.\n"
f"Based on the conversation below, produce EXACTLY {n} short questions the user might ask next.\n"
"Requirements:\n"
"- Questions must be relevant to the preceding conversation.\n"
"- Questions must be written in the same language as the user.\n"
"- Keep each question concise (ideally <= 20 words / <= 40 Chinese characters).\n"
"- Do NOT include numbering, markdown, or any extra text.\n"
"- Output MUST be a JSON array of strings only.\n"
)
user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions"
try:
raw = await run_oneshot_llm(
system_instruction=system_instruction,
user_content=user_content,
run_name="suggest_agent",
app_config=config,
model_name=body.model_name,
thread_id=thread_id,
)
suggestions = _parse_json_string_list(raw) or []
cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()]
cleaned = cleaned[:n]
return SuggestionsResponse(suggestions=cleaned)
except Exception as exc:
logger.exception("Failed to generate suggestions: thread_id=%s err=%s", thread_id, exc)
return SuggestionsResponse(suggestions=[])