This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -185,6 +185,7 @@ pub fn compile_result_draft_from_seed(
level_id: "puzzle-level-1".to_string(),
level_name: level_name.clone(),
picture_description,
picture_reference: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
@@ -240,6 +241,7 @@ pub fn build_form_draft_from_parts(
level_id: "puzzle-level-1".to_string(),
level_name: String::new(),
picture_description: picture_description.clone().unwrap_or_default(),
picture_reference: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
@@ -344,6 +346,7 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
&draft.anchor_pack.visual_subject.value,
&draft.summary,
),
picture_reference: None,
candidates: draft.candidates.clone(),
selected_candidate_id: draft.selected_candidate_id.clone(),
cover_image_src: draft.cover_image_src.clone(),
@@ -429,6 +432,7 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft
next_index,
),
picture_description,
picture_reference: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
@@ -671,10 +675,12 @@ pub fn normalize_puzzle_levels(
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
let picture_description = normalize_required_string(&level.picture_description)
.unwrap_or_else(|| format!("{}关画面", index + 1));
let picture_reference = level.picture_reference.and_then(normalize_required_string);
let level_name = normalize_required_string(&level.level_name).unwrap_or_default();
level.level_id = level_id;
level.level_name = level_name;
level.picture_description = picture_description;
level.picture_reference = picture_reference;
level.generation_status = normalize_required_string(&level.generation_status)
.unwrap_or_else(|| "idle".to_string());
normalized_levels.push(level);
@@ -2791,6 +2797,7 @@ mod tests {
level_id: "puzzle-level-1".to_string(),
level_name: format!("{profile_id} 关"),
picture_description: "summary".to_string(),
picture_reference: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/cover.png".to_string()),
@@ -3004,6 +3011,7 @@ mod tests {
level_id: "puzzle-level-1".to_string(),
level_name: "第一关".to_string(),
picture_description: "第一关画面".to_string(),
picture_reference: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-1.png".to_string()),
@@ -3014,6 +3022,7 @@ mod tests {
level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(),
picture_reference: None,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()),

View File

@@ -0,0 +1,208 @@
//! 拼图创意 Agent 模板协议。
//!
//! 这里只保存拼图模块自己的模板事实HTTP / SSE 展示字段由 api-server
//! 再映射到 shared-contracts避免通用 Agent 复制拼图模板规则。
use serde::{Deserialize, Serialize};
pub const PUZZLE_PHASE1_TEMPLATE_ID: &str = "puzzle.default-creative";
pub const PUZZLE_PHASE1_TEMPLATE_TITLE: &str = "创意拼图";
pub const PUZZLE_FAMILY_KEEPSTAKE_TEMPLATE_ID: &str = "puzzle.family-keepsake";
pub const PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID: &str = "puzzle.travel-memory";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PuzzleCreativePricingUnit {
Point,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleCreativeSupportedLevelMode {
Single,
Multi,
SingleOrMulti,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleCreativeLevelGenerationMode {
SingleLevel,
MultiLevel,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeCostRange {
pub min_points: u32,
pub max_points: u32,
pub pricing_unit: PuzzleCreativePricingUnit,
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleCreativeDraftEditableFieldPath {
#[serde(rename = "workTitle")]
WorkTitle,
#[serde(rename = "workDescription")]
WorkDescription,
#[serde(rename = "workTags")]
WorkTags,
#[serde(rename = "levels[].levelName")]
LevelName,
#[serde(rename = "levels[].pictureDescription")]
LevelPictureDescription,
#[serde(rename = "levels[].pictureReference")]
LevelPictureReference,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeImageGenerationPolicy {
pub allow_uploaded_image_directly: bool,
pub allow_generated_images: bool,
pub allow_per_level_reference_image: bool,
pub default_candidate_count_per_level: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeTemplateProtocol {
pub template_id: String,
pub title: String,
pub summary: String,
pub preview_image_src: Option<String>,
pub supported_level_mode: PuzzleCreativeSupportedLevelMode,
pub min_level_count: u32,
pub max_level_count: u32,
pub default_level_count: u32,
pub cost_range: PuzzleCreativeCostRange,
pub required_draft_fields: Vec<PuzzleCreativeDraftEditableFieldPath>,
pub image_policy: PuzzleCreativeImageGenerationPolicy,
}
fn shared_required_draft_fields() -> Vec<PuzzleCreativeDraftEditableFieldPath> {
vec![
PuzzleCreativeDraftEditableFieldPath::WorkTitle,
PuzzleCreativeDraftEditableFieldPath::WorkDescription,
PuzzleCreativeDraftEditableFieldPath::WorkTags,
PuzzleCreativeDraftEditableFieldPath::LevelName,
PuzzleCreativeDraftEditableFieldPath::LevelPictureDescription,
PuzzleCreativeDraftEditableFieldPath::LevelPictureReference,
]
}
fn shared_image_policy() -> PuzzleCreativeImageGenerationPolicy {
PuzzleCreativeImageGenerationPolicy {
allow_uploaded_image_directly: true,
allow_generated_images: true,
allow_per_level_reference_image: true,
default_candidate_count_per_level: 1,
}
}
fn build_template(
template_id: &str,
title: &str,
summary: &str,
default_level_count: u32,
min_points: u32,
max_points: u32,
reason: &str,
) -> PuzzleCreativeTemplateProtocol {
PuzzleCreativeTemplateProtocol {
template_id: template_id.to_string(),
title: title.to_string(),
summary: summary.to_string(),
preview_image_src: None,
supported_level_mode: PuzzleCreativeSupportedLevelMode::SingleOrMulti,
min_level_count: 1,
max_level_count: 6,
default_level_count,
cost_range: PuzzleCreativeCostRange {
min_points,
max_points,
pricing_unit: PuzzleCreativePricingUnit::Point,
reason: reason.to_string(),
},
required_draft_fields: shared_required_draft_fields(),
image_policy: shared_image_policy(),
}
}
pub fn retrieve_puzzle_template_catalog() -> Vec<PuzzleCreativeTemplateProtocol> {
vec![
build_template(
PUZZLE_PHASE1_TEMPLATE_ID,
PUZZLE_PHASE1_TEMPLATE_TITLE,
"把图文灵感整理成可编辑、可试玩的拼图草稿。",
1,
2,
12,
"按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准",
),
build_template(
PUZZLE_FAMILY_KEEPSTAKE_TEMPLATE_ID,
"家庭纪念拼图",
"把合影、节日或成长瞬间做成温暖的纪念拼图。",
3,
4,
14,
"按纪念主题多关卡和图片候选估算,实际扣费以后端任务结算为准",
),
build_template(
PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID,
"旅行记忆拼图",
"把一次出行拆成地点、风景和故事节点拼图。",
3,
4,
16,
"按旅行节点和每关图片生成次数估算,实际扣费以后端任务结算为准",
),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase1_template_contains_cost_range_and_editable_fields() {
let template = retrieve_puzzle_template_catalog()
.into_iter()
.find(|template| template.template_id == PUZZLE_PHASE1_TEMPLATE_ID)
.expect("template should exist");
assert_eq!(template.template_id, PUZZLE_PHASE1_TEMPLATE_ID);
assert_eq!(template.cost_range.min_points, 2);
assert_eq!(template.cost_range.max_points, 12);
assert!(
template
.required_draft_fields
.contains(&PuzzleCreativeDraftEditableFieldPath::LevelPictureReference)
);
}
#[test]
fn catalog_exposes_multiple_phase1_puzzle_subtemplates() {
let catalog = retrieve_puzzle_template_catalog();
assert!(catalog.len() >= 3);
assert!(
catalog
.iter()
.any(|template| template.template_id == PUZZLE_FAMILY_KEEPSTAKE_TEMPLATE_ID)
);
assert!(
catalog
.iter()
.any(|template| template.template_id == PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID)
);
assert!(
catalog
.iter()
.all(|template| template.supported_level_mode
== PuzzleCreativeSupportedLevelMode::SingleOrMulti)
);
}
}

View File

@@ -0,0 +1,529 @@
//! 拼图创意 Agent 草稿工具。
//!
//! 通用 Agent 只能把模型输出交给这些工具;字段归一化、模板关卡数和可编辑
//! 字段白名单都收口在拼图模块,避免 api-server 复制草稿业务规则。
use serde::{Deserialize, Serialize};
use serde_json::Value;
use shared_kernel::{normalize_required_string, normalize_string_list};
use crate::{
application::{
build_form_anchor_pack, build_result_preview, normalize_puzzle_draft,
normalize_puzzle_levels, sync_primary_level_fields,
},
creative_templates::{
PuzzleCreativeCostRange, PuzzleCreativeDraftEditableFieldPath,
PuzzleCreativeLevelGenerationMode, PuzzleCreativeSupportedLevelMode,
PuzzleCreativeTemplateProtocol, retrieve_puzzle_template_catalog,
},
domain::{
PUZZLE_MAX_TAG_COUNT, PUZZLE_MIN_TAG_COUNT, PuzzleDraftLevel, PuzzleFormDraft,
PuzzleResultDraft,
},
errors::PuzzleFieldError,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreativePuzzleLevelDraftInput {
pub level_name: String,
pub picture_description: String,
#[serde(default)]
pub picture_reference: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreativePuzzleDraftToolInput {
pub template_id: String,
pub template_cost_range: PuzzleCreativeCostRange,
pub work_title: String,
pub work_description: String,
pub work_tags: Vec<String>,
pub levels: Vec<CreativePuzzleLevelDraftInput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeTemplateSelection {
pub template_id: String,
pub title: String,
pub reason: String,
pub cost_range: PuzzleCreativeCostRange,
pub supported_level_mode: PuzzleCreativeSupportedLevelMode,
pub selected_level_mode: PuzzleCreativeLevelGenerationMode,
pub planned_level_count: u32,
pub requires_user_confirmation: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleLevelImagePlanInput {
pub template_id: String,
pub selected_level_mode: PuzzleCreativeLevelGenerationMode,
pub levels: Vec<CreativePuzzleLevelDraftInput>,
pub cost_range: PuzzleCreativeCostRange,
#[serde(default)]
pub candidate_count_per_level: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleImageGenerationPlanLevel {
pub level_id: String,
pub level_name: String,
pub picture_description: String,
pub image_prompt: String,
#[serde(default)]
pub picture_reference: Option<String>,
pub candidate_count: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleImageGenerationPlan {
pub mode: PuzzleCreativeLevelGenerationMode,
pub template_id: String,
pub estimated_cost_range: PuzzleCreativeCostRange,
pub levels: Vec<PuzzleImageGenerationPlanLevel>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleDraftFieldPatchOperation {
Set,
Append,
Replace,
Remove,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleDraftFieldPatch {
pub field_path: PuzzleCreativeDraftEditableFieldPath,
pub operation: PuzzleDraftFieldPatchOperation,
#[serde(default)]
pub level_id: Option<String>,
pub value: Value,
pub rationale: String,
}
pub fn validate_puzzle_template_selection(
selection: &PuzzleCreativeTemplateSelection,
) -> Result<(), PuzzleFieldError> {
let template = resolve_phase1_template(&selection.template_id)?;
if selection.cost_range != template.cost_range
|| selection.supported_level_mode != template.supported_level_mode
|| !selection.requires_user_confirmation
{
return Err(PuzzleFieldError::InvalidOperation);
}
validate_level_count(
selection.planned_level_count,
&selection.selected_level_mode,
&template,
)
}
pub fn build_puzzle_draft_from_creative_fields(
input: CreativePuzzleDraftToolInput,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let template = resolve_phase1_template(&input.template_id)?;
if input.template_cost_range != template.cost_range {
return Err(PuzzleFieldError::InvalidOperation);
}
validate_level_count(
input.levels.len() as u32,
&if input.levels.len() > 1 {
PuzzleCreativeLevelGenerationMode::MultiLevel
} else {
PuzzleCreativeLevelGenerationMode::SingleLevel
},
&template,
)?;
let work_title =
normalize_required_string(&input.work_title).ok_or(PuzzleFieldError::MissingText)?;
let work_description =
normalize_required_string(&input.work_description).ok_or(PuzzleFieldError::MissingText)?;
let tags = normalize_theme_tags_for_creative(input.work_tags)?;
let anchor_pack = build_form_anchor_pack(
work_title.as_str(),
input
.levels
.first()
.map(|level| level.picture_description.as_str())
.unwrap_or(work_description.as_str()),
);
let levels = input
.levels
.into_iter()
.enumerate()
.map(|(index, level)| {
let picture_description = normalize_required_string(&level.picture_description)
.ok_or(PuzzleFieldError::MissingText)?;
Ok(PuzzleDraftLevel {
level_id: format!("puzzle-level-{}", index + 1),
level_name: normalize_required_string(&level.level_name)
.unwrap_or_else(|| format!("{}", index + 1)),
picture_description,
picture_reference: level.picture_reference.and_then(normalize_required_string),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
})
})
.collect::<Result<Vec<_>, PuzzleFieldError>>()?;
let mut draft = PuzzleResultDraft {
work_title: work_title.clone(),
work_description: work_description.clone(),
level_name: levels
.first()
.map(|level| level.level_name.clone())
.unwrap_or_default(),
summary: work_description.clone(),
theme_tags: tags,
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels,
form_draft: Some(PuzzleFormDraft {
work_title: Some(work_title),
work_description: Some(work_description),
picture_description: None,
}),
};
sync_primary_level_fields(&mut draft);
Ok(normalize_puzzle_draft(draft))
}
pub fn plan_puzzle_level_images(
input: PuzzleLevelImagePlanInput,
) -> Result<PuzzleImageGenerationPlan, PuzzleFieldError> {
let template = resolve_phase1_template(&input.template_id)?;
validate_level_count(
input.levels.len() as u32,
&input.selected_level_mode,
&template,
)?;
if input.cost_range != template.cost_range {
return Err(PuzzleFieldError::InvalidOperation);
}
let candidate_count = input.candidate_count_per_level.unwrap_or(1).clamp(1, 1);
let levels = input
.levels
.into_iter()
.enumerate()
.map(|(index, level)| {
let picture_description = normalize_required_string(&level.picture_description)
.ok_or(PuzzleFieldError::MissingText)?;
let level_name = normalize_required_string(&level.level_name)
.unwrap_or_else(|| format!("{}", index + 1));
Ok(PuzzleImageGenerationPlanLevel {
level_id: format!("puzzle-level-{}", index + 1),
image_prompt: build_level_image_prompt(&level_name, &picture_description),
level_name,
picture_description,
picture_reference: level.picture_reference.and_then(normalize_required_string),
candidate_count,
})
})
.collect::<Result<Vec<_>, PuzzleFieldError>>()?;
Ok(PuzzleImageGenerationPlan {
mode: input.selected_level_mode,
template_id: input.template_id,
estimated_cost_range: input.cost_range,
levels,
})
}
pub fn apply_puzzle_draft_field_patch(
draft: PuzzleResultDraft,
patch: PuzzleDraftFieldPatch,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
if patch.operation != PuzzleDraftFieldPatchOperation::Set
&& patch.operation != PuzzleDraftFieldPatchOperation::Replace
{
return Err(PuzzleFieldError::InvalidOperation);
}
let mut next_draft = normalize_puzzle_draft(draft);
match patch.field_path {
PuzzleCreativeDraftEditableFieldPath::WorkTitle => {
next_draft.work_title = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::WorkDescription => {
next_draft.work_description = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::WorkTags => {
next_draft.theme_tags =
normalize_theme_tags_for_creative(value_as_string_list(&patch.value)?)?;
}
PuzzleCreativeDraftEditableFieldPath::LevelName => {
let level = mutable_level_for_patch(&mut next_draft, patch.level_id.as_deref())?;
level.level_name = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::LevelPictureDescription => {
let level = mutable_level_for_patch(&mut next_draft, patch.level_id.as_deref())?;
level.picture_description = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::LevelPictureReference => {
let level = mutable_level_for_patch(&mut next_draft, patch.level_id.as_deref())?;
level.picture_reference = value_as_optional_string(&patch.value);
}
}
let levels = normalize_puzzle_levels(next_draft.levels.clone(), &next_draft.theme_tags)?;
next_draft.levels = levels;
sync_primary_level_fields(&mut next_draft);
let _ = build_result_preview(&next_draft, Some("百梦主"));
Ok(next_draft)
}
fn resolve_phase1_template(
template_id: &str,
) -> Result<PuzzleCreativeTemplateProtocol, PuzzleFieldError> {
let normalized_template_id =
normalize_required_string(template_id).ok_or(PuzzleFieldError::InvalidOperation)?;
retrieve_puzzle_template_catalog()
.into_iter()
.find(|template| template.template_id == normalized_template_id)
.ok_or(PuzzleFieldError::InvalidOperation)
}
fn validate_level_count(
count: u32,
mode: &PuzzleCreativeLevelGenerationMode,
template: &PuzzleCreativeTemplateProtocol,
) -> Result<(), PuzzleFieldError> {
if count < template.min_level_count || count > template.max_level_count {
return Err(PuzzleFieldError::InvalidOperation);
}
if matches!(mode, PuzzleCreativeLevelGenerationMode::SingleLevel) && count != 1 {
return Err(PuzzleFieldError::InvalidOperation);
}
if matches!(mode, PuzzleCreativeLevelGenerationMode::MultiLevel) && count < 2 {
return Err(PuzzleFieldError::InvalidOperation);
}
Ok(())
}
fn normalize_theme_tags_for_creative(values: Vec<String>) -> Result<Vec<String>, PuzzleFieldError> {
let mut tags = Vec::new();
for tag in normalize_string_list(values) {
if !tags.contains(&tag) {
tags.push(tag);
}
if tags.len() >= PUZZLE_MAX_TAG_COUNT {
break;
}
}
if tags.len() < PUZZLE_MIN_TAG_COUNT {
return Err(PuzzleFieldError::InvalidTagCount);
}
Ok(tags)
}
fn build_level_image_prompt(level_name: &str, picture_description: &str) -> String {
format!("{level_name}{picture_description}。清晰主体,适合拼图切块。")
}
fn mutable_level_for_patch<'a>(
draft: &'a mut PuzzleResultDraft,
level_id: Option<&str>,
) -> Result<&'a mut PuzzleDraftLevel, PuzzleFieldError> {
if let Some(level_id) = level_id.and_then(normalize_required_string) {
return draft
.levels
.iter_mut()
.find(|level| level.level_id == level_id)
.ok_or(PuzzleFieldError::InvalidOperation);
}
draft
.levels
.first_mut()
.ok_or(PuzzleFieldError::InvalidOperation)
}
fn value_as_required_string(value: &Value) -> Result<String, PuzzleFieldError> {
value
.as_str()
.and_then(normalize_required_string)
.ok_or(PuzzleFieldError::MissingText)
}
fn value_as_optional_string(value: &Value) -> Option<String> {
value.as_str().and_then(normalize_required_string)
}
fn value_as_string_list(value: &Value) -> Result<Vec<String>, PuzzleFieldError> {
value
.as_array()
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.collect::<Vec<_>>()
})
.ok_or(PuzzleFieldError::InvalidOperation)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creative_templates::{PUZZLE_PHASE1_TEMPLATE_ID, PuzzleCreativePricingUnit};
fn cost_range() -> PuzzleCreativeCostRange {
PuzzleCreativeCostRange {
min_points: 2,
max_points: 12,
pricing_unit: PuzzleCreativePricingUnit::Point,
reason: "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准".to_string(),
}
}
#[test]
fn creative_draft_builds_single_level_with_summary_and_plan() {
let input_level = CreativePuzzleLevelDraftInput {
level_name: "第一关".to_string(),
picture_description: "生日蛋糕和朋友合影。".to_string(),
picture_reference: Some("https://assets.example.test/birthday.png".to_string()),
};
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
template_cost_range: cost_range(),
work_title: "生日拼图".to_string(),
work_description: "把生日照片做成一关拼图。".to_string(),
work_tags: vec!["生日".to_string(), "朋友".to_string(), "纪念".to_string()],
levels: vec![input_level.clone()],
})
.expect("single level draft should build");
let plan = plan_puzzle_level_images(PuzzleLevelImagePlanInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
selected_level_mode: PuzzleCreativeLevelGenerationMode::SingleLevel,
levels: vec![input_level],
cost_range: cost_range(),
candidate_count_per_level: Some(3),
})
.expect("single level image plan should build");
assert_eq!(draft.work_title, "生日拼图");
assert_eq!(draft.work_description, "把生日照片做成一关拼图。");
assert_eq!(draft.summary, "把生日照片做成一关拼图。");
assert_eq!(draft.level_name, "第一关");
assert_eq!(draft.levels.len(), 1);
assert_eq!(
draft.levels[0].picture_reference.as_deref(),
Some("https://assets.example.test/birthday.png")
);
assert_eq!(plan.mode, PuzzleCreativeLevelGenerationMode::SingleLevel);
assert_eq!(plan.levels.len(), 1);
assert_eq!(plan.levels[0].candidate_count, 1);
assert!(plan.levels[0].image_prompt.contains("生日蛋糕和朋友合影"));
}
#[test]
fn creative_draft_builds_multi_level_picture_references() {
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
template_cost_range: cost_range(),
work_title: "旅行拼图".to_string(),
work_description: "把旅行照片做成系列拼图。".to_string(),
work_tags: vec!["旅行".to_string(), "照片".to_string(), "纪念".to_string()],
levels: vec![
CreativePuzzleLevelDraftInput {
level_name: "第一站".to_string(),
picture_description: "海边合影".to_string(),
picture_reference: Some("asset-1".to_string()),
},
CreativePuzzleLevelDraftInput {
level_name: "第二站".to_string(),
picture_description: "山顶日落".to_string(),
picture_reference: Some("asset-2".to_string()),
},
],
})
.expect("draft should build");
assert_eq!(draft.work_title, "旅行拼图");
assert_eq!(draft.theme_tags, vec!["旅行", "照片", "纪念"]);
assert_eq!(draft.levels.len(), 2);
assert_eq!(
draft.levels[1].picture_reference.as_deref(),
Some("asset-2")
);
}
#[test]
fn creative_draft_accepts_catalog_subtemplate_id() {
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: crate::creative_templates::PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID.to_string(),
template_cost_range: PuzzleCreativeCostRange {
min_points: 4,
max_points: 16,
pricing_unit: PuzzleCreativePricingUnit::Point,
reason: "按旅行节点和每关图片生成次数估算,实际扣费以后端任务结算为准"
.to_string(),
},
work_title: "旅行记忆".to_string(),
work_description: "把旅行照片做成系列拼图。".to_string(),
work_tags: vec!["旅行".to_string(), "照片".to_string(), "纪念".to_string()],
levels: vec![
CreativePuzzleLevelDraftInput {
level_name: "第一站".to_string(),
picture_description: "海边合影".to_string(),
picture_reference: Some("asset-1".to_string()),
},
CreativePuzzleLevelDraftInput {
level_name: "第二站".to_string(),
picture_description: "山顶日落".to_string(),
picture_reference: Some("asset-2".to_string()),
},
],
})
.expect("subtemplate draft should build");
assert_eq!(draft.work_title, "旅行记忆");
assert_eq!(draft.levels.len(), 2);
}
#[test]
fn draft_patch_rejects_non_whitelisted_operation() {
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
template_cost_range: cost_range(),
work_title: "旅行拼图".to_string(),
work_description: "把旅行照片做成系列拼图。".to_string(),
work_tags: vec!["旅行".to_string(), "照片".to_string(), "纪念".to_string()],
levels: vec![CreativePuzzleLevelDraftInput {
level_name: "第一站".to_string(),
picture_description: "海边合影".to_string(),
picture_reference: None,
}],
})
.expect("draft should build");
let error = apply_puzzle_draft_field_patch(
draft,
PuzzleDraftFieldPatch {
field_path: PuzzleCreativeDraftEditableFieldPath::WorkTitle,
operation: PuzzleDraftFieldPatchOperation::Remove,
level_id: None,
value: Value::Null,
rationale: "测试".to_string(),
},
)
.expect_err("remove should be rejected");
assert_eq!(error, PuzzleFieldError::InvalidOperation);
}
}

View File

@@ -129,6 +129,8 @@ pub struct PuzzleDraftLevel {
pub level_id: String,
pub level_name: String,
pub picture_description: String,
#[serde(default)]
pub picture_reference: Option<String>,
pub candidates: Vec<PuzzleGeneratedImageCandidate>,
pub selected_candidate_id: Option<String>,
pub cover_image_src: Option<String>,

View File

@@ -1,11 +1,15 @@
mod application;
mod commands;
mod creative_templates;
mod creative_tools;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use creative_templates::*;
pub use creative_tools::*;
pub use domain::*;
pub use errors::*;
pub use events::*;