拒绝未完整结束的图标规范元数据

图标规范元数据解析统一检查 OpenAI Chat finish_reason

length 与 content_filter 结果进入既有重试循环

新增完整 JSON 仍被不完整原因拒绝的回归测试
This commit is contained in:
2026-08-08 18:13:06 +08:00
parent 84ec79a8bb
commit ae78e3bd5b
@@ -664,33 +664,22 @@ async fn run_icon_spec_extra_param_llm(
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
match llm_client.run(request.clone()).await {
Ok(response) => {
let text = response.text.trim();
if text.is_empty() {
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "editor-icon-spec-llm",
"operation": "complete-extra-param",
"message": "LLM 未返回可用文本",
}),
));
}
} else {
match parse_extra_param(text) {
Ok(extra_param) => return Ok(extra_param),
Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {}
Err(_) => {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": "editor-icon-spec-llm",
"operation": "complete-extra-param",
"message": "LLM 返回内容不符合图标规范参数契约",
})));
}
}
Ok(response) => match parse_extra_param_response(
response.text.as_str(),
response.finish_reason.as_deref(),
) {
Ok(extra_param) => return Ok(extra_param),
Err(_) if attempt < ICON_SPEC_LLM_MAX_ATTEMPTS => {}
Err(error) => {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "editor-icon-spec-llm",
"operation": "complete-extra-param",
"message": error.message(),
}),
));
}
}
},
Err(error) if is_retryable_llm_error(&error) => {
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
return Err(map_llm_error(error));
@@ -703,6 +692,39 @@ async fn run_icon_spec_extra_param_llm(
unreachable!("icon spec extra param retry loop always returns")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExtraParamResponseError {
Empty,
Incomplete,
Invalid,
}
impl ExtraParamResponseError {
fn message(self) -> &'static str {
match self {
Self::Empty => "LLM 未返回可用文本",
Self::Incomplete => "LLM 返回内容未完整结束",
Self::Invalid => "LLM 返回内容不符合图标规范参数契约",
}
}
}
fn parse_extra_param_response(
text: &str,
finish_reason: Option<&str>,
) -> Result<ExtraParam, ExtraParamResponseError> {
if finish_reason.is_some_and(|reason| {
platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason)
}) {
return Err(ExtraParamResponseError::Incomplete);
}
let text = text.trim();
if text.is_empty() {
return Err(ExtraParamResponseError::Empty);
}
parse_extra_param(text).map_err(|_| ExtraParamResponseError::Invalid)
}
fn parse_extra_param(text: &str) -> Result<ExtraParam, ()> {
let mut extra_param = serde_json::from_str::<ExtraParam>(text).map_err(|_| ())?;
for value in [
@@ -2752,6 +2774,20 @@ mod tests {
assert!(parse_extra_param(oversized.to_string().as_str()).is_err());
}
#[test]
fn extra_param_rejects_explicitly_incomplete_llm_responses() {
let complete_json =
r#"{"genre":"策略","theme":"现代军事","useCase":"PC","targetUser":"策略游戏玩家"}"#;
for finish_reason in ["length", "content_filter"] {
assert_eq!(
parse_extra_param_response(complete_json, Some(finish_reason)),
Err(ExtraParamResponseError::Incomplete)
);
}
assert!(parse_extra_param_response(complete_json, Some("stop")).is_ok());
}
#[test]
fn icon_spec_generation_text_enforces_prompt_max_length() {
assert!(