21209910dd
修复外部 API 参数覆盖与图标描述数组边界 提前校验 Agent 正式引用并收紧 SpacetimeDB 对象一致性 修正自然语言提示词转义与纯文本格式校验 移除图标规范占位提示词并避免重复下载参考图 补充定向测试与对应架构文档
393 lines
13 KiB
Rust
393 lines
13 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Extension, State},
|
|
http::StatusCode,
|
|
};
|
|
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
|
|
use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
use std::time::Duration;
|
|
use tokio::time::sleep;
|
|
|
|
use crate::{
|
|
api_response::json_success_body,
|
|
auth::AuthenticatedAccessToken,
|
|
http_error::AppError,
|
|
platform_errors::{is_retryable_llm_error, map_llm_error},
|
|
prompt::icon_spec::{
|
|
PROMPT_MAX_LEN, build_refine_art_style_prompt, build_refine_play_setting_prompt,
|
|
},
|
|
request_context::RequestContext,
|
|
state::AppState,
|
|
};
|
|
|
|
const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3;
|
|
const ICON_SPEC_LLM_MAX_OUTPUT_TOKENS: u32 = 1024;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct RefineGamePlayRequest {
|
|
play_setting: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub(crate) struct RefineArtStyleRequest {
|
|
art_style: String,
|
|
}
|
|
|
|
pub(crate) async fn refine_game_play(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<RefineGamePlayRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let input = require_refine_text(payload.play_setting, "playSetting", "玩法设定")?;
|
|
let prompt = build_refine_play_setting_prompt(input.as_str());
|
|
let refined = run_refine_text_llm(&state, prompt).await?;
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
json!({ "playSetting": refined }),
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn refine_art_style(
|
|
State(state): State<AppState>,
|
|
Extension(request_context): Extension<RequestContext>,
|
|
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
|
Json(payload): Json<RefineArtStyleRequest>,
|
|
) -> Result<Json<Value>, AppError> {
|
|
let input = require_refine_text(payload.art_style, "artStyle", "美术风格")?;
|
|
let prompt = build_refine_art_style_prompt(input.as_str());
|
|
let refined = run_refine_text_llm(&state, prompt).await?;
|
|
Ok(json_success_body(
|
|
Some(&request_context),
|
|
json!({ "artStyle": refined }),
|
|
))
|
|
}
|
|
|
|
fn require_refine_text(
|
|
value: String,
|
|
field: &'static str,
|
|
field_label: &'static str,
|
|
) -> Result<String, AppError> {
|
|
let value = value.trim().to_string();
|
|
if value.is_empty() {
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "editor-icon-spec",
|
|
"field": field,
|
|
"message": format!("{field_label}不能为空"),
|
|
})),
|
|
);
|
|
}
|
|
let actual_length = value.chars().count();
|
|
if actual_length > PROMPT_MAX_LEN {
|
|
return Err(
|
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
"provider": "editor-icon-spec",
|
|
"field": field,
|
|
"maxLength": PROMPT_MAX_LEN,
|
|
"actualLength": actual_length,
|
|
"message": format!("{field_label}不能超过 {PROMPT_MAX_LEN} 个字符"),
|
|
})),
|
|
);
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum RefinedTextError {
|
|
Empty,
|
|
TooLong,
|
|
InvalidFormat,
|
|
Incomplete,
|
|
}
|
|
|
|
impl RefinedTextError {
|
|
fn message(self) -> String {
|
|
match self {
|
|
Self::Empty => "LLM 未返回可用文本".to_string(),
|
|
Self::TooLong => format!("LLM 返回文本超过 {PROMPT_MAX_LEN} 个字符"),
|
|
Self::InvalidFormat => "LLM 返回内容不是可直接使用的纯文本".to_string(),
|
|
Self::Incomplete => "LLM 返回内容未完整结束".to_string(),
|
|
}
|
|
}
|
|
|
|
fn repair_instruction(self) -> String {
|
|
match self {
|
|
Self::Empty => "上次输出为空。请重新输出一条可直接使用的纯文本。".to_string(),
|
|
Self::TooLong => format!(
|
|
"上次输出超过 {PROMPT_MAX_LEN} 个字符。请压缩到限制内,只保留可直接使用的正文。"
|
|
),
|
|
Self::InvalidFormat => {
|
|
"上次输出包含 Markdown、结构化数据或解释性前缀。请只输出可直接使用的纯文本正文。"
|
|
.to_string()
|
|
}
|
|
Self::Incomplete => {
|
|
"上次输出因长度、内容过滤或上游中止而未完整结束。请重新输出完整的纯文本正文。"
|
|
.to_string()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_refined_text_response(
|
|
value: &str,
|
|
finish_reason: Option<&str>,
|
|
) -> Result<String, RefinedTextError> {
|
|
if finish_reason.is_some_and(|reason| {
|
|
platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason)
|
|
}) {
|
|
return Err(RefinedTextError::Incomplete);
|
|
}
|
|
normalize_refined_text(value)
|
|
}
|
|
|
|
fn normalize_refined_text(value: &str) -> Result<String, RefinedTextError> {
|
|
let value = value.trim();
|
|
if value.is_empty() {
|
|
return Err(RefinedTextError::Empty);
|
|
}
|
|
if value.chars().count() > PROMPT_MAX_LEN {
|
|
return Err(RefinedTextError::TooLong);
|
|
}
|
|
let disallowed_prefixes = [
|
|
"```",
|
|
"#",
|
|
"> ",
|
|
"- ",
|
|
"* ",
|
|
"+ ",
|
|
"说明:",
|
|
"说明:",
|
|
"解释:",
|
|
"解释:",
|
|
];
|
|
let has_disallowed_format = value.lines().any(|line| {
|
|
let line = line.trim_start();
|
|
disallowed_prefixes
|
|
.iter()
|
|
.any(|prefix| line.starts_with(prefix))
|
|
|| line.starts_with(['{', '['])
|
|
|| is_markdown_ordered_list_item(line)
|
|
|| ["**", "__", "~~"]
|
|
.iter()
|
|
.any(|marker| line.contains(marker))
|
|
|| line.contains('`')
|
|
});
|
|
if has_disallowed_format {
|
|
return Err(RefinedTextError::InvalidFormat);
|
|
}
|
|
Ok(value.to_string())
|
|
}
|
|
|
|
fn is_markdown_ordered_list_item(line: &str) -> bool {
|
|
let digit_count = line
|
|
.chars()
|
|
.take_while(|character| character.is_ascii_digit())
|
|
.count();
|
|
if digit_count == 0 {
|
|
return false;
|
|
}
|
|
let suffix = &line[digit_count..];
|
|
suffix.starts_with(". ") || suffix.starts_with(") ")
|
|
}
|
|
|
|
async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String, AppError> {
|
|
let configured_llm_client = state.vector_engine_llm_client().ok_or_else(|| {
|
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
|
"provider": "editor-icon-spec-llm",
|
|
"message": "服务端尚未配置可用的 VectorEngine LLM API Key",
|
|
}))
|
|
})?;
|
|
let retry_backoff_ms = configured_llm_client.config().retry_backoff_ms();
|
|
let llm_client = configured_llm_client.clone().with_max_retries(0);
|
|
let mut request = build_refine_text_llm_request(prompt.clone());
|
|
|
|
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
|
|
match llm_client.run(request.clone()).await {
|
|
Ok(response) => match normalize_refined_text_response(
|
|
response.text.as_str(),
|
|
response.finish_reason.as_deref(),
|
|
) {
|
|
Ok(text) => return Ok(text),
|
|
Err(error) if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS => {
|
|
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
|
|
json!({
|
|
"provider": "editor-icon-spec-llm",
|
|
"maxLength": PROMPT_MAX_LEN,
|
|
"message": error.message(),
|
|
}),
|
|
));
|
|
}
|
|
Err(error) => {
|
|
request =
|
|
build_refine_text_llm_repair_request(prompt.clone(), response.text, error);
|
|
}
|
|
},
|
|
Err(error) if is_retryable_llm_error(&error) => {
|
|
if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS {
|
|
return Err(map_llm_error(error));
|
|
}
|
|
}
|
|
Err(error) => return Err(map_llm_error(error)),
|
|
}
|
|
sleep_before_icon_spec_llm_retry(retry_backoff_ms, attempt).await;
|
|
}
|
|
unreachable!("icon spec refine retry loop always returns")
|
|
}
|
|
|
|
fn build_refine_text_llm_request(prompt: String) -> LlmRunRequest {
|
|
build_refine_text_llm_request_with_messages(vec![LlmMessage::user(prompt)])
|
|
}
|
|
|
|
fn build_refine_text_llm_repair_request(
|
|
prompt: String,
|
|
invalid_response: String,
|
|
error: RefinedTextError,
|
|
) -> LlmRunRequest {
|
|
build_refine_text_llm_request_with_messages(vec![
|
|
LlmMessage::user(prompt),
|
|
LlmMessage::assistant(invalid_response),
|
|
LlmMessage::user(error.repair_instruction()),
|
|
])
|
|
}
|
|
|
|
fn build_refine_text_llm_request_with_messages(messages: Vec<LlmMessage>) -> LlmRunRequest {
|
|
LlmRunRequest::new(messages)
|
|
.with_model(EDITOR_AGENT_GPT5_MODEL)
|
|
.with_max_output_tokens(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS)
|
|
.with_openai_chat()
|
|
}
|
|
|
|
async fn sleep_before_icon_spec_llm_retry(retry_backoff_ms: u64, attempt: u32) {
|
|
let delay_ms = retry_backoff_ms.saturating_mul(u64::from(attempt));
|
|
if delay_ms > 0 {
|
|
sleep(Duration::from_millis(delay_ms)).await;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn refine_request_uses_play_setting_contract() {
|
|
let request: RefineGamePlayRequest = serde_json::from_value(json!({
|
|
"playSetting": "回合制占点"
|
|
}))
|
|
.expect("playSetting request should parse");
|
|
assert_eq!(request.play_setting, "回合制占点");
|
|
assert!(
|
|
serde_json::from_value::<RefineGamePlayRequest>(json!({
|
|
"gamePlay": "旧字段"
|
|
}))
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refine_text_enforces_prompt_max_length_by_unicode_characters() {
|
|
assert!(
|
|
require_refine_text("玩".repeat(PROMPT_MAX_LEN), "playSetting", "玩法设定").is_ok()
|
|
);
|
|
assert!(
|
|
require_refine_text("玩".repeat(PROMPT_MAX_LEN + 1), "playSetting", "玩法设定")
|
|
.is_err()
|
|
);
|
|
assert!(normalize_refined_text("画".repeat(PROMPT_MAX_LEN).as_str()).is_ok());
|
|
assert!(normalize_refined_text("画".repeat(PROMPT_MAX_LEN + 1).as_str()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn refined_text_rejects_structured_or_explanatory_output() {
|
|
assert_eq!(
|
|
normalize_refined_text("```json\n{}\n```").unwrap_err(),
|
|
RefinedTextError::InvalidFormat
|
|
);
|
|
assert_eq!(
|
|
normalize_refined_text("说明:优化后的玩法").unwrap_err(),
|
|
RefinedTextError::InvalidFormat
|
|
);
|
|
assert_eq!(
|
|
normalize_refined_text(r#"{"playSetting":"回合制"}"#).unwrap_err(),
|
|
RefinedTextError::InvalidFormat
|
|
);
|
|
for invalid in [
|
|
"1. 回合制占点",
|
|
"2) 资源调度",
|
|
"##优化后的玩法",
|
|
"**回合制占点**",
|
|
"正文说明\n{\"playSetting\":\"回合制\"}",
|
|
"正文说明\n[\"回合制\"]",
|
|
] {
|
|
assert_eq!(
|
|
normalize_refined_text(invalid).unwrap_err(),
|
|
RefinedTextError::InvalidFormat,
|
|
"structured or Markdown output should fail: {invalid}",
|
|
);
|
|
}
|
|
assert_eq!(
|
|
normalize_refined_text("回合制占点,围绕资源调度形成攻防循环")
|
|
.expect("plain text should pass"),
|
|
"回合制占点,围绕资源调度形成攻防循环"
|
|
);
|
|
assert_eq!(
|
|
normalize_refined_text("核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。")
|
|
.expect("multi-line plain text should pass"),
|
|
"核心循环围绕回合制占点。\n玩家通过资源调度形成攻防转换。"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refined_text_rejects_incomplete_provider_completion() {
|
|
assert_eq!(
|
|
normalize_refined_text_response("看似完整但实际被截断", Some("length")).unwrap_err(),
|
|
RefinedTextError::Incomplete
|
|
);
|
|
assert_eq!(
|
|
normalize_refined_text_response("完整结果", Some("stop"))
|
|
.expect("stop completion should pass"),
|
|
"完整结果"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refine_request_bounds_provider_output() {
|
|
let request = build_refine_text_llm_request("优化玩法".to_string());
|
|
|
|
assert_eq!(
|
|
request.max_output_tokens,
|
|
Some(ICON_SPEC_LLM_MAX_OUTPUT_TOKENS)
|
|
);
|
|
assert_eq!(request.api_kind, platform_llm::LlmApiKind::OpenAiChat);
|
|
}
|
|
|
|
#[test]
|
|
fn refine_repair_request_preserves_invalid_output_and_adds_targeted_correction() {
|
|
let request = build_refine_text_llm_repair_request(
|
|
"优化玩法".to_string(),
|
|
"```markdown\n说明:优化结果\n```".to_string(),
|
|
RefinedTextError::InvalidFormat,
|
|
);
|
|
|
|
assert_eq!(request.messages.len(), 3);
|
|
assert_eq!(request.messages[0].role, platform_llm::LlmMessageRole::User);
|
|
assert_eq!(
|
|
request.messages[1].role,
|
|
platform_llm::LlmMessageRole::Assistant
|
|
);
|
|
assert_eq!(
|
|
request.messages[1].content,
|
|
"```markdown\n说明:优化结果\n```"
|
|
);
|
|
assert_eq!(request.messages[2].role, platform_llm::LlmMessageRole::User);
|
|
assert!(
|
|
request.messages[2]
|
|
.content
|
|
.contains("只输出可直接使用的纯文本正文")
|
|
);
|
|
}
|
|
}
|