补齐背景色决策外部失败审计

为gpt-5-mini背景色决策记录OTLP和失败事件

透传角色、图标、UI提取和动作生成的审计上下文

补充失败分类测试和后端契约说明
This commit is contained in:
2026-07-10 09:17:48 +00:00
parent a701fa0240
commit f64fff461b
4 changed files with 384 additions and 2 deletions
@@ -689,6 +689,14 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
reference_count: 1,
source_image_data_url: Some(source_data_url.clone()),
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state: &state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: Some(owner_user_id.clone()),
profile_id: project_id.clone(),
request_id: Some(request_context.request_id().to_string()),
},
}),
)
.await?;
// 把实际选定的背景色落进资产记录,供后续以该资产为参考图时反查旧背景色。
@@ -1467,6 +1467,17 @@ pub(crate) async fn generate_editor_image_for_owner(
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
},
}),
)
.await?,
)
@@ -2980,6 +2991,17 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
},
}),
)
.await?;
let screen_color = screen_background_decision.color;
@@ -3266,6 +3288,17 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
reference_count,
source_image_data_url: None,
},
Some(&crate::editor_screen_background_decision::EditorScreenBackgroundDecisionAuditContext {
state,
context: crate::external_api_audit::ExternalApiAuditContext {
user_id: caller.audit_subject_user_id.clone(),
profile_id: caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
request_id: Some(request_context.request_id().to_string()),
},
}),
)
.await?;
let screen_color = screen_background_decision.color;
@@ -1,4 +1,6 @@
use platform_llm::{LlmClient, LlmMessage, LlmResponseReasoningEffort, LlmTextRequest};
use platform_llm::{
LlmClient, LlmError, LlmMessage, LlmProvider, LlmResponseReasoningEffort, LlmTextRequest,
};
use serde_json::json;
use tracing::{info, warn};
@@ -11,7 +13,12 @@ use crate::{
ScreenBackgroundColorSafetyReport, decode_image_data_url,
filter_editor_screen_background_colors, safety_report_log_json,
},
external_api_audit::{
ExternalApiAuditContext, ExternalApiFailureDraft, is_retryable_external_api_failure,
record_external_api_failure,
},
http_error::AppError,
state::AppState,
};
const EDITOR_SCREEN_BACKGROUND_AUTO_VALUE: &str = "auto";
@@ -49,6 +56,15 @@ impl EditorScreenBackgroundDecisionKind {
Self::UiDesignAssetExtraction => "UI 设计图素材提取",
}
}
fn audit_operation_key(self) -> &'static str {
match self {
Self::Character => "character",
Self::CharacterAnimation => "character_animation",
Self::IconSpritesheet => "icon_spritesheet",
Self::UiDesignAssetExtraction => "ui_design_asset_extraction",
}
}
}
#[derive(Clone, Debug)]
@@ -63,10 +79,17 @@ pub(crate) struct EditorScreenBackgroundDecisionInput {
pub(crate) source_image_data_url: Option<String>,
}
/// 自动决策虽可业务降级,但已发出的 LLM 调用失败仍须进入 OTLP 和失败事件审计。
pub(crate) struct EditorScreenBackgroundDecisionAuditContext<'a> {
pub(crate) state: &'a AppState,
pub(crate) context: ExternalApiAuditContext,
}
pub(crate) async fn resolve_editor_screen_background_color(
llm_client: Option<&LlmClient>,
vision_llm_client: Option<&LlmClient>,
mut input: EditorScreenBackgroundDecisionInput,
audit: Option<&EditorScreenBackgroundDecisionAuditContext<'_>>,
) -> Result<EditorScreenBackgroundDecision, AppError> {
if !is_auto_screen_background_color(input.screen_color.as_deref()) {
return Ok(EditorScreenBackgroundDecision {
@@ -146,6 +169,11 @@ pub(crate) async fn resolve_editor_screen_background_color(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let audit_prompt_chars = system_prompt
.chars()
.count()
.saturating_add(user_prompt.chars().count());
let audit_reference_image_count = usize::from(source_image_data_url.is_some());
let mut last_error: Option<String> = None;
for attempt in 1..=EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS {
let user_message = match source_image_data_url {
@@ -195,6 +223,24 @@ pub(crate) async fn resolve_editor_screen_background_color(
message = message.as_str(),
"editor_screen_background_auto_decision_invalid_response"
);
record_editor_screen_background_decision_failure(
audit,
llm_client,
input.kind,
decision_model,
true,
attempt,
"response_parse",
Some(200),
false,
false,
message.as_str(),
None,
Some(response.content.as_str()),
audit_prompt_chars,
audit_reference_image_count,
)
.await;
last_error = Some(message);
}
}
@@ -207,6 +253,18 @@ pub(crate) async fn resolve_editor_screen_background_color(
message = message.as_str(),
"editor_screen_background_auto_decision_request_failed"
);
record_editor_screen_background_decision_llm_error(
audit,
llm_client,
input.kind,
decision_model,
true,
attempt,
&error,
audit_prompt_chars,
audit_reference_image_count,
)
.await;
last_error = Some(message);
}
}
@@ -227,6 +285,178 @@ pub(crate) async fn resolve_editor_screen_background_color(
})
}
async fn record_editor_screen_background_decision_llm_error(
audit: Option<&EditorScreenBackgroundDecisionAuditContext<'_>>,
llm_client: &LlmClient,
kind: EditorScreenBackgroundDecisionKind,
decision_model: Option<&'static str>,
uses_responses_api: bool,
attempt: u8,
error: &LlmError,
prompt_chars: usize,
reference_image_count: usize,
) {
let (failure_stage, status_code, timeout, retryable, error_source, raw_excerpt) = match error
{
LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => return,
LlmError::Timeout { .. } => ("request_timeout", None, true, true, None, None),
LlmError::Connectivity { message, .. } => (
"request_send",
None,
false,
true,
Some(message.as_str()),
None,
),
LlmError::Upstream {
status_code,
message,
} => (
"upstream_status",
Some(*status_code),
false,
is_retryable_external_api_failure(Some(*status_code), false, false),
None,
Some(message.as_str()),
),
LlmError::Deserialize(message) => (
"response_parse",
Some(200),
false,
false,
Some(message.as_str()),
None,
),
LlmError::EmptyResponse => ("missing_response", Some(200), false, false, None, None),
LlmError::StreamUnavailable => ("response_body", Some(200), false, true, None, None),
LlmError::Transport(message) => (
"transport",
None,
false,
true,
Some(message.as_str()),
None,
),
};
record_editor_screen_background_decision_failure(
audit,
llm_client,
kind,
decision_model,
uses_responses_api,
attempt,
failure_stage,
status_code,
timeout,
retryable,
error.to_string().as_str(),
error_source,
raw_excerpt,
prompt_chars,
reference_image_count,
)
.await;
}
#[allow(clippy::too_many_arguments)]
async fn record_editor_screen_background_decision_failure(
audit: Option<&EditorScreenBackgroundDecisionAuditContext<'_>>,
llm_client: &LlmClient,
kind: EditorScreenBackgroundDecisionKind,
decision_model: Option<&'static str>,
uses_responses_api: bool,
attempt: u8,
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
retryable: bool,
error_message: &str,
error_source: Option<&str>,
raw_excerpt: Option<&str>,
prompt_chars: usize,
reference_image_count: usize,
) {
let Some(audit) = audit else {
return;
};
let draft = build_editor_screen_background_decision_failure_draft(
llm_client,
kind,
decision_model,
uses_responses_api,
attempt,
failure_stage,
status_code,
timeout,
retryable,
error_message,
error_source,
raw_excerpt,
prompt_chars,
reference_image_count,
&audit.context,
);
record_external_api_failure(audit.state, draft).await;
}
#[allow(clippy::too_many_arguments)]
fn build_editor_screen_background_decision_failure_draft(
llm_client: &LlmClient,
kind: EditorScreenBackgroundDecisionKind,
decision_model: Option<&'static str>,
uses_responses_api: bool,
attempt: u8,
failure_stage: &'static str,
status_code: Option<u16>,
timeout: bool,
retryable: bool,
error_message: &str,
error_source: Option<&str>,
raw_excerpt: Option<&str>,
prompt_chars: usize,
reference_image_count: usize,
context: &ExternalApiAuditContext,
) -> ExternalApiFailureDraft {
let endpoint = if uses_responses_api {
llm_client.config().responses_url()
} else {
llm_client.config().chat_completions_url()
};
let model = decision_model.unwrap_or_else(|| llm_client.config().model());
let message = format!(
"{}背景色自动决策第{attempt}/{EDITOR_SCREEN_BACKGROUND_DECISION_MAX_ATTEMPTS}次失败:{error_message}",
kind.label()
);
ExternalApiFailureDraft::new(
editor_screen_background_decision_provider(llm_client.config().provider()),
endpoint,
format!(
"editor_screen_background_decision:{}:{}",
kind.audit_operation_key(),
model
),
failure_stage,
message,
)
.with_status_code(status_code)
.with_timeout(timeout)
.with_retryable(retryable)
.with_error_source(error_source.map(str::to_string))
.with_raw_excerpt(raw_excerpt.map(str::to_string))
.with_prompt_chars(Some(prompt_chars))
.with_reference_image_count(Some(reference_image_count))
.with_image_model(None)
.with_audit_context(context)
}
fn editor_screen_background_decision_provider(provider: LlmProvider) -> &'static str {
match provider {
LlmProvider::OpenAiCompatible => "vector-engine",
LlmProvider::Ark => "ark",
LlmProvider::DashScope => "dash-scope",
}
}
/// 解出参考图并做候选色硬过滤;任何一步失败都返回 None(降级为不过滤)。
fn resolve_screen_background_safety_report(
input: &EditorScreenBackgroundDecisionInput,
@@ -398,6 +628,7 @@ mod tests {
reference_count: 1,
source_image_data_url: None,
},
None,
)
.await
.expect("manual color should parse");
@@ -420,6 +651,7 @@ mod tests {
reference_count: 2,
source_image_data_url: None,
},
None,
)
.await
.expect("auto fallback should succeed");
@@ -442,6 +674,7 @@ mod tests {
reference_count: 1,
source_image_data_url: None,
},
None,
)
.await
.expect("missing color should use auto fallback");
@@ -483,6 +716,111 @@ mod tests {
);
}
#[test]
fn decision_invalid_response_failure_audit_is_success_response_parse() {
use platform_llm::{LlmConfig, LlmProvider};
let client = LlmClient::new(
LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://vector.example/v1".to_string(),
"test-key".to_string(),
"fallback-model".to_string(),
20_000,
0,
1,
)
.expect("test LLM config should build"),
)
.expect("test LLM client should build");
let audit = build_editor_screen_background_decision_failure_draft(
&client,
EditorScreenBackgroundDecisionKind::Character,
Some("gpt-5-mini"),
true,
3,
"response_parse",
Some(200),
false,
false,
"LLM 返回了无法识别的背景色",
None,
Some("{\"hex\":\"#123456\"}"),
456,
0,
&ExternalApiAuditContext {
user_id: Some("user-1".to_string()),
profile_id: Some("project-1".to_string()),
request_id: Some("request-1".to_string()),
},
);
let tracking =
crate::external_api_audit::build_external_api_failure_tracking_draft(&audit);
assert_eq!(audit.provider, "vector-engine");
assert_eq!(audit.endpoint, "https://vector.example/v1/responses");
assert_eq!(
audit.operation,
"editor_screen_background_decision:character:gpt-5-mini"
);
assert_eq!(audit.failure_stage, "response_parse");
assert_eq!(audit.status_code, Some(200));
assert!(!audit.retryable);
assert_eq!(tracking.metadata["statusClass"], "2xx");
assert_eq!(tracking.metadata["promptChars"], 456);
assert_eq!(tracking.metadata["referenceImageCount"], 0);
assert_eq!(tracking.metadata["rawExcerpt"], "{\"hex\":\"#123456\"}");
assert_eq!(tracking.metadata["userId"], "user-1");
assert_eq!(tracking.metadata["profileId"], "project-1");
assert_eq!(tracking.metadata["requestId"], "request-1");
}
#[test]
fn decision_timeout_failure_audit_keeps_transport_semantics() {
use platform_llm::{LlmConfig, LlmProvider};
let client = LlmClient::new(
LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://vector.example/v1".to_string(),
"test-key".to_string(),
"fallback-model".to_string(),
20_000,
0,
1,
)
.expect("test LLM config should build"),
)
.expect("test LLM client should build");
let audit = build_editor_screen_background_decision_failure_draft(
&client,
EditorScreenBackgroundDecisionKind::CharacterAnimation,
Some("gpt-5-mini"),
true,
3,
"request_timeout",
None,
true,
true,
"LLM 请求超时,累计尝试 2 次",
None,
None,
789,
1,
&ExternalApiAuditContext::default(),
);
let tracking =
crate::external_api_audit::build_external_api_failure_tracking_draft(&audit);
assert_eq!(audit.failure_stage, "request_timeout");
assert_eq!(audit.status_code, None);
assert!(audit.timeout);
assert!(audit.retryable);
assert_eq!(tracking.metadata["statusClass"], "transport");
assert_eq!(tracking.metadata["promptChars"], 789);
assert_eq!(tracking.metadata["referenceImageCount"], 1);
}
#[tokio::test]
async fn auto_decision_fallback_avoids_colliding_default_color() {
use base64::Engine as _;
@@ -519,6 +857,7 @@ mod tests {
reference_count: 1,
source_image_data_url: Some(data_url),
},
None,
)
.await
.expect("decision should fall back");
@@ -620,6 +959,7 @@ mod tests {
reference_count: 1,
source_image_data_url: None,
},
None,
)
.await
.expect("live 无图决策应成功");
@@ -653,6 +993,7 @@ mod tests {
reference_count: 1,
source_image_data_url: Some(solid_source_image_data_url()),
},
None,
)
.await
.expect("live 有图决策应成功");