补齐背景色决策外部失败审计
为gpt-5-mini背景色决策记录OTLP和失败事件 透传角色、图标、UI提取和动作生成的审计上下文 补充失败分类测试和后端契约说明
This commit is contained in:
@@ -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 有图决策应成功");
|
||||
|
||||
Reference in New Issue
Block a user