fix: 完善拼消消模板运行规则

This commit is contained in:
2026-06-11 00:50:18 +08:00
parent c98c3de96d
commit 21ac5642e8
19 changed files with 1952 additions and 317 deletions

View File

@@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
use std::{
collections::BTreeMap,
env, fs,
path::{Path as FsPath, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
@@ -38,8 +40,8 @@ use crate::{
},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
require_openai_image_settings,
DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body,
create_openai_image_generation, require_openai_image_settings,
},
request_context::RequestContext,
state::AppState,
@@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet {
image: DownloadedOpenAiImage,
}
#[derive(Clone, Debug)]
struct PuzzleClearImageDebugRun {
root: PathBuf,
run_id: String,
}
impl PuzzleClearImageDebugRun {
fn record_spec(
&self,
sheet_specs: &[PuzzleClearAtlasSheetSpec],
groups: &[PuzzleClearPatternGroup],
) {
let sheets = sheet_specs
.iter()
.map(|spec| {
json!({
"sheetId": spec.sheet_id,
"layout": spec.layout.iter().map(|row| row.to_vec()).collect::<Vec<_>>(),
"layoutPrompt": spec.layout_prompt,
})
})
.collect::<Vec<_>>();
let groups = groups
.iter()
.map(|group| {
json!({
"groupId": group.group_id.as_str(),
"shape": group.shape.as_str(),
"width": group.width,
"height": group.height,
"atlasX": group.atlas_x,
"atlasY": group.atlas_y,
"atlasWidth": group.atlas_width,
"atlasHeight": group.atlas_height,
})
})
.collect::<Vec<_>>();
self.write_json(
"specs/puzzle-clear-debug-spec.json",
&json!({
"cellSize": PUZZLE_CLEAR_ATLAS_CELL_SIZE,
"sheetColumns": PUZZLE_CLEAR_SHEET_COLUMNS,
"sheetRows": PUZZLE_CLEAR_SHEET_ROWS,
"finalAtlasColumns": PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
"finalAtlasRows": PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
"sheets": sheets,
"groups": groups,
}),
"记录拼消消调试规格失败",
);
}
fn record_board_background_request(&self, prompt: &str) {
self.write_text(
"prompts/board-background.txt",
prompt,
"记录拼消消底图 prompt 失败",
);
self.write_json(
"requests/board-background.json",
&json!({
"endpoint": "/v1/images/generations",
"body": build_openai_image_request_body(
prompt,
Some("文字、水印、按钮、教程浮层、明显网格"),
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
1,
&[],
),
}),
"记录拼消消底图 request 失败",
);
}
fn record_board_background_image(
&self,
task_id: &str,
actual_prompt: Option<&str>,
image: &DownloadedOpenAiImage,
) {
let extension = puzzle_clear_debug_image_extension(image);
self.write_bytes(
format!("background/board-background.{extension}"),
image.bytes.as_slice(),
"记录拼消消底图图片失败",
);
self.write_json(
"responses/board-background.json",
&json!({
"taskId": task_id,
"actualPrompt": actual_prompt,
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
}),
"记录拼消消底图 response 摘要失败",
);
}
fn record_board_background_error(&self, error: &AppError) {
self.write_json(
"responses/board-background.error.json",
&puzzle_clear_debug_error_json(error),
"记录拼消消底图错误失败",
);
}
fn record_sheet_request(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
prompt: &str,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
self.write_text(
format!("prompts/{}.txt", sheet_spec.sheet_id),
prompt,
"记录拼消消 sheet prompt 失败",
);
self.write_json(
format!("requests/{}-{attempt_id}.json", sheet_spec.sheet_id),
&json!({
"endpoint": "/v1/images/generations",
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"body": build_openai_image_request_body(
prompt,
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
1,
&[],
),
}),
"记录拼消消 sheet request 失败",
);
}
fn record_sheet_generation_error(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
error: &AppError,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
self.write_json(
format!("responses/{}-{attempt_id}.error.json", sheet_spec.sheet_id),
&json!({
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"stage": "generation",
"error": puzzle_clear_debug_error_json(error),
}),
"记录拼消消 sheet 生成错误失败",
);
}
fn record_sheet_attempt_image(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
task_id: &str,
actual_prompt: Option<&str>,
image: &DownloadedOpenAiImage,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
let extension = puzzle_clear_debug_image_extension(image);
self.write_bytes(
format!("sheets/{}-{attempt_id}.{extension}", sheet_spec.sheet_id),
image.bytes.as_slice(),
"记录拼消消 sheet 原图失败",
);
self.write_json(
format!("responses/{}-{attempt_id}.json", sheet_spec.sheet_id),
&json!({
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"taskId": task_id,
"actualPrompt": actual_prompt,
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
}),
"记录拼消消 sheet response 摘要失败",
);
}
fn record_sheet_quality(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
task_id: &str,
image: &DownloadedOpenAiImage,
quality_error: Option<&AppError>,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
match build_puzzle_clear_sheet_quality_debug_report(
sheet_spec,
attempt_index,
task_id,
image,
quality_error,
) {
Ok(report) => {
self.write_json(
format!("reports/{}-{attempt_id}.quality.json", sheet_spec.sheet_id),
&report,
"记录拼消消 sheet 质量报告失败",
);
}
Err(error) => self.write_json(
format!("reports/{}-{attempt_id}.quality-error.json", sheet_spec.sheet_id),
&puzzle_clear_debug_error_json(&error),
"记录拼消消 sheet 质量报告错误失败",
),
}
self.write_sheet_cells(sheet_spec, attempt_index, image);
}
fn record_sheet_accepted(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
task_id: &str,
image: &DownloadedOpenAiImage,
) {
let extension = puzzle_clear_debug_image_extension(image);
self.write_bytes(
format!("accepted/{}.{extension}", sheet_spec.sheet_id),
image.bytes.as_slice(),
"记录拼消消 accepted sheet 失败",
);
self.write_json(
format!("accepted/{}.json", sheet_spec.sheet_id),
&json!({
"sheetId": sheet_spec.sheet_id,
"taskId": task_id,
"accepted": true,
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
}),
"记录拼消消 accepted sheet 摘要失败",
);
}
fn record_atlas_image(
&self,
image: &DownloadedOpenAiImage,
generated_sheets: &[PuzzleClearGeneratedSheet],
) {
self.write_bytes(
"atlas/puzzle-clear-atlas.png",
image.bytes.as_slice(),
"记录拼消消最终 atlas 失败",
);
self.write_json(
"atlas/puzzle-clear-atlas.json",
&json!({
"image": {
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
"acceptedSheets": generated_sheets
.iter()
.map(|sheet| json!({
"sheetId": sheet.spec.sheet_id,
"taskId": sheet.task_id.as_str(),
}))
.collect::<Vec<_>>(),
}),
"记录拼消消最终 atlas 摘要失败",
);
}
fn record_summary_success(
&self,
session_id: &str,
profile_id: &str,
sheet_count: usize,
card_count: usize,
) {
self.write_text(
"summary.md",
format!(
concat!(
"# 拼消消 runtime 生图调试\n\n",
"- runId: `{}`\n",
"- sessionId: `{}`\n",
"- profileId: `{}`\n",
"- status: `ready`\n",
"- acceptedSheets: `{}`\n",
"- cardCount: `{}`\n\n",
"关键查看顺序:\n\n",
"1. `prompts/` 和 `requests/`:真实请求内容。\n",
"2. `sheets/`:每次 attempt 的原始 sheet。\n",
"3. `reports/*.quality.json`:每格质量指标和 hard/advisory findings。\n",
"4. `cells/<sheet-attempt>/contact-sheet.png`4x6 裁切总览。\n",
"5. `accepted/`:最终通过门禁的 sheet。\n",
"6. `atlas/puzzle-clear-atlas.png`:最终 atlas 预览。\n"
),
self.run_id, session_id, profile_id, sheet_count, card_count
),
"记录拼消消调试 summary 失败",
);
}
fn write_sheet_cells(
&self,
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
image: &DownloadedOpenAiImage,
) {
let attempt_id = puzzle_clear_debug_attempt_id(attempt_index);
let result = (|| -> Result<(), AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试裁切解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
let mut contact = image::RgbaImage::from_pixel(
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_COLUMNS,
PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_ROWS,
image::Rgba([255, 255, 255, 0]),
);
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds =
puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let cropped = source
.crop_imm(bounds.x0, bounds.y0, bounds.width(), bounds.height())
.resize_exact(
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
image::imageops::FilterType::Lanczos3,
)
.to_rgba8();
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(cropped.clone())
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试裁切写入失败:{error}", sheet_spec.sheet_id),
}))
})?;
self.write_bytes(
format!(
"cells/{}-{attempt_id}/r{:02}-c{:02}-{}.png",
sheet_spec.sheet_id,
row + 1,
col + 1,
sanitize_puzzle_clear_debug_segment(group_id, "cell"),
),
cursor.into_inner().as_slice(),
"记录拼消消 sheet 裁切格失败",
);
image::imageops::overlay(
&mut contact,
&cropped,
i64::from(col * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
i64::from(row * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
);
}
}
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(contact)
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试 contact sheet 写入失败:{error}", sheet_spec.sheet_id),
}))
})?;
self.write_bytes(
format!("cells/{}-{attempt_id}/contact-sheet.png", sheet_spec.sheet_id),
cursor.into_inner().as_slice(),
"记录拼消消 sheet contact sheet 失败",
);
Ok(())
})();
if let Err(error) = result {
self.write_json(
format!("cells/{}-{attempt_id}/error.json", sheet_spec.sheet_id),
&puzzle_clear_debug_error_json(&error),
"记录拼消消 sheet 裁切错误失败",
);
}
}
fn write_text(
&self,
relative_path: impl AsRef<FsPath>,
content: impl AsRef<str>,
action: &'static str,
) {
self.write_result(action, || {
let target = self.root.join(relative_path.as_ref());
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, content.as_ref().as_bytes())
});
}
fn write_json(&self, relative_path: impl AsRef<FsPath>, value: &Value, action: &'static str) {
self.write_result(action, || {
let target = self.root.join(relative_path.as_ref());
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let mut bytes = serde_json::to_vec_pretty(value)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?;
bytes.push(b'\n');
fs::write(target, bytes)
});
}
fn write_bytes(
&self,
relative_path: impl AsRef<FsPath>,
bytes: &[u8],
action: &'static str,
) {
self.write_result(action, || {
let target = self.root.join(relative_path.as_ref());
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, bytes)
});
}
fn write_result(
&self,
action: &'static str,
operation: impl FnOnce() -> std::io::Result<()>,
) {
if let Err(error) = operation() {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
run_id = self.run_id,
debug_path = %self.root.display(),
error = %error,
action,
"拼消消本地生图调试包写入失败,已忽略"
);
}
}
}
fn maybe_create_puzzle_clear_image_debug_run(
session_id: &str,
profile_id: &str,
theme_prompt: &str,
) -> Option<PuzzleClearImageDebugRun> {
if !puzzle_clear_image_debug_enabled() {
return None;
}
let base_dir = puzzle_clear_image_debug_runs_dir();
let run_id = env::var("PUZZLE_CLEAR_IMAGE_DEBUG_RUN_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
format!(
"runtime-{}-{}-{}",
sanitize_puzzle_clear_debug_segment(session_id, "session"),
sanitize_puzzle_clear_debug_segment(profile_id, "profile"),
current_utc_micros()
)
});
let run_id = sanitize_puzzle_clear_debug_segment(run_id.as_str(), "runtime");
let root = base_dir.join(run_id.as_str());
if let Err(error) = fs::create_dir_all(&root) {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
debug_path = %root.display(),
error = %error,
"拼消消本地生图调试包初始化失败,已关闭本次记录"
);
return None;
}
let debug_run = PuzzleClearImageDebugRun { root, run_id };
debug_run.write_json(
"manifest.json",
&json!({
"runId": debug_run.run_id.as_str(),
"source": "api-server-runtime",
"playType": "puzzle-clear",
"sessionId": session_id,
"profileId": profile_id,
"themePrompt": theme_prompt,
"createdAt": format_timestamp_micros(current_utc_micros()),
"env": {
"PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED": "1",
"PUZZLE_CLEAR_IMAGE_DEBUG_DIR": puzzle_clear_image_debug_runs_dir().display().to_string(),
},
}),
"记录拼消消调试 manifest 失败",
);
if let Err(error) = fs::write(
puzzle_clear_image_debug_runs_dir().join("latest-runtime.txt"),
debug_run.root.to_string_lossy().as_bytes(),
) {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
error = %error,
"拼消消本地生图调试包 latest-runtime.txt 写入失败,已忽略"
);
}
tracing::info!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
run_id = debug_run.run_id.as_str(),
debug_path = %debug_run.root.display(),
"拼消消本地生图调试包已开启"
);
Some(debug_run)
}
fn puzzle_clear_image_debug_enabled() -> bool {
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
fn puzzle_clear_image_debug_runs_dir() -> PathBuf {
env::var("PUZZLE_CLEAR_IMAGE_DEBUG_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
puzzle_clear_repo_root()
.join(".app")
.join("puzzle-clear-image-debug")
.join("runs")
})
}
fn puzzle_clear_repo_root() -> PathBuf {
let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
for candidate in current_dir.ancestors() {
if candidate.join("server-rs").is_dir() && candidate.join("package.json").is_file() {
return candidate.to_path_buf();
}
}
current_dir
}
fn puzzle_clear_debug_attempt_id(attempt_index: usize) -> String {
format!("attempt-{:02}", attempt_index + 1)
}
fn puzzle_clear_debug_image_extension(image: &DownloadedOpenAiImage) -> String {
sanitize_puzzle_clear_debug_segment(
image
.extension
.trim()
.trim_start_matches('.')
.to_ascii_lowercase()
.as_str(),
"png",
)
}
fn sanitize_puzzle_clear_debug_segment(raw: &str, fallback: &str) -> String {
let mut value = String::new();
for character in raw.chars() {
if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
value.push(character);
} else {
value.push('-');
}
}
let value = value.trim_matches('-').trim_matches('.').to_string();
if value.is_empty() {
fallback.to_string()
} else {
value
}
}
fn puzzle_clear_debug_error_json(error: &AppError) -> Value {
json!({
"statusCode": error.status_code().as_u16(),
"code": error.code(),
"message": error.body_text(),
"details": error.details(),
})
}
fn build_puzzle_clear_sheet_quality_debug_report(
sheet_spec: &PuzzleClearAtlasSheetSpec,
attempt_index: usize,
task_id: &str,
image: &DownloadedOpenAiImage,
quality_error: Option<&AppError>,
) -> Result<Value, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 调试质量报告解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
let mut hard_findings = Vec::new();
let mut advisory_findings = Vec::new();
let mut cells = Vec::new();
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let quality =
analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds);
let cell_label = format!("{}行第{}", row + 1, col + 1);
let mut cell_findings = Vec::new();
let mut cell_advisory_findings = Vec::new();
if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL {
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
let finding = format!("{cell_label} 空白格有主体");
hard_findings.push(finding.clone());
cell_findings.push(finding);
}
} else if group_id != PUZZLE_CLEAR_SHEET_FILLER_CELL {
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
let finding = format!("{cell_label} 主体过少");
hard_findings.push(finding.clone());
cell_findings.push(finding);
}
if quality.strongest_internal_seam_ratio
> PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
{
let finding = format!("{cell_label} 单格内部疑似拼接线");
hard_findings.push(finding.clone());
cell_findings.push(finding);
}
if quality.exposed_edge_count >= 2
&& quality.strongest_edge_ratio
> PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
{
let finding = format!("{cell_label} 主体贴到不同图案边界");
advisory_findings.push(finding.clone());
cell_advisory_findings.push(finding);
}
}
cells.push(json!({
"row": row + 1,
"col": col + 1,
"groupId": group_id,
"discarded": is_puzzle_clear_sheet_discarded_cell(group_id),
"bounds": {
"x0": bounds.x0,
"y0": bounds.y0,
"x1": bounds.x1,
"y1": bounds.y1,
},
"foregroundRatio": quality.foreground_ratio,
"exposedEdgeCount": quality.exposed_edge_count,
"strongestEdgeRatio": quality.strongest_edge_ratio,
"strongestInternalSeamRatio": quality.strongest_internal_seam_ratio,
"hardFindings": cell_findings,
"advisoryFindings": cell_advisory_findings,
}));
}
}
Ok(json!({
"sheetId": sheet_spec.sheet_id,
"attempt": attempt_index + 1,
"taskId": task_id,
"accepted": quality_error.is_none(),
"image": {
"width": source_width,
"height": source_height,
"mimeType": image.mime_type.as_str(),
"extension": image.extension.as_str(),
"byteLength": image.bytes.len(),
},
"thresholds": {
"foregroundDiff": PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD,
"minForegroundRatio": PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO,
"blankMaxForegroundRatio": PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO,
"edgeRatio": PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD,
"strongEdgeRatio": PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD,
"internalSeamDiff": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD,
"internalSeamRatio": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD,
"internalSeamSideContrast": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD,
"internalSeamSideTextureMax": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX,
},
"hardFindings": hard_findings,
"advisoryFindings": advisory_findings,
"qualityError": quality_error.map(puzzle_clear_debug_error_json),
"cells": cells,
}))
}
async fn maybe_prepare_puzzle_clear_assets_inner(
state: &AppState,
request_context: &RequestContext,
@@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
.map(ToString::to_string)
.unwrap_or_else(|| {
build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX)
});
});
payload.profile_id = Some(profile_id.clone());
let image_debug_run = maybe_create_puzzle_clear_image_debug_run(
session_id,
profile_id.as_str(),
payload.theme_prompt.as_deref().unwrap_or_default(),
);
if payload.generate_board_background.unwrap_or(false)
&& payload
@@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
owner_user_id,
profile_id.as_str(),
board_background_prompt.unwrap_or(theme_prompt),
image_debug_run.as_ref(),
)
.await?;
payload.board_background_asset = Some(background_asset);
@@ -683,6 +1406,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
.map(|group| (group.group_id.clone(), group))
.collect::<BTreeMap<_, _>>();
let sheet_specs = puzzle_clear_atlas_sheet_specs();
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_spec(&sheet_specs, &groups);
}
let settings = require_openai_image_settings(state)
.map(|settings| {
settings.with_external_api_audit_context(
@@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
sheet_spec.sheet_id,
attempt_index + 1
);
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_request(&sheet_spec, attempt_index, sheet_prompt.as_str());
}
let generated = match create_openai_image_generation(
&http_client,
&settings,
@@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
{
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
}
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
@@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
continue;
}
Err(error) => {
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error);
}
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
@@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
}
};
let task_id = generated.task_id;
let actual_prompt = generated.actual_prompt;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
@@ -755,8 +1491,30 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
})),
)
})?;
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_attempt_image(
&sheet_spec,
attempt_index,
task_id.as_str(),
actual_prompt.as_deref(),
&image,
);
}
let quality_result = validate_puzzle_clear_sheet_quality(&image, &sheet_spec);
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_quality(
&sheet_spec,
attempt_index,
task_id.as_str(),
&image,
quality_result.as_ref().err(),
);
}
match quality_result {
Ok(()) => {
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image);
}
accepted_sheet = Some(PuzzleClearGeneratedSheet {
spec: sheet_spec,
prompt: sheet_prompt.clone(),
@@ -821,6 +1579,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_atlas_image(&atlas_image, &generated_sheets);
}
let atlas_prompt = generated_sheets
.iter()
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
@@ -864,6 +1625,14 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
payload.atlas_asset = Some(atlas_asset);
payload.pattern_groups = Some(groups);
payload.card_assets = Some(card_assets);
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_summary_success(
session_id,
profile_id.as_str(),
generated_sheets.len(),
payload.card_assets.as_ref().map_or(0, Vec::len),
);
}
tracing::info!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id,
@@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background(
owner_user_id: &str,
profile_id: &str,
theme_prompt: &str,
image_debug_run: Option<&PuzzleClearImageDebugRun>,
) -> Result<PuzzleClearImageAsset, Response> {
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
if let Some(debug_run) = image_debug_run {
debug_run.record_board_background_request(prompt.as_str());
}
let settings = require_openai_image_settings(state)
.map(|settings| {
settings.with_external_api_audit_context(
@@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background(
)
.await
.map_err(|error| {
if let Some(debug_run) = image_debug_run {
debug_run.record_board_background_error(&error);
}
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
let task_id = generated.task_id;
let actual_prompt = generated.actual_prompt;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
@@ -1095,6 +1872,9 @@ async fn generate_and_persist_puzzle_clear_board_background(
})),
)
})?;
if let Some(debug_run) = image_debug_run {
debug_run.record_board_background_image(task_id.as_str(), actual_prompt.as_deref(), &image);
}
persist_puzzle_clear_generated_image_asset(
state,
owner_user_id,
@@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
if value.contains("发布需要")
|| value.contains("不能为空")
|| value.contains("必须")
|| value.contains("无权") =>
|| value.contains("无权")
|| value.contains("puzzle-clear 坐标无效")
|| value.contains("puzzle-clear 目标格子没有卡牌")
|| value.contains("puzzle-clear 当前 run 不在 playing 状态")
|| value.contains("puzzle-clear 当前关卡已经超时") =>
{
StatusCode::BAD_REQUEST
}