Files
Genarrative/server-rs/crates/api-server/src/llm/icon_specs.rs
T
k88936 21209910dd
Project CI / Frontend tests (pull_request) Successful in 2m41s
Project CI / Native shell tests (pull_request) Successful in 12m9s
Project CI / Repository checks (pull_request) Failing after 7s
Project CI / Backend tests (pull_request) Failing after 8s
修复图标生成引用与提示词校验
修复外部 API 参数覆盖与图标描述数组边界

提前校验 Agent 正式引用并收紧 SpacetimeDB 对象一致性

修正自然语言提示词转义与纯文本格式校验

移除图标规范占位提示词并避免重复下载参考图

补充定向测试与对应架构文档
2026-08-07 12:18:01 +08:00

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("只输出可直接使用的纯文本正文")
);
}
}