重构提取提示词生成逻辑,移除文本拼接方式,新增 YAML 序列化结构与测试

This commit is contained in:
2026-09-11 14:54:00 +08:00
parent 1303592a86
commit 7c7d55dcab
3 changed files with 124 additions and 86 deletions
@@ -252,37 +252,6 @@ mod tests {
assert!(next_image_batch(&separation, &separation.trees[0]).is_empty());
}
#[test]
fn next_extract_prompt_contains_previous_rework_notes() {
let node = SeparationNode {
id: NodeId::new("image").unwrap(),
kind: SeparationNodeKind::ImageTarget,
global_pos_x_px: 0,
global_pos_y_px: 0,
width_px: 1,
height_px: 1,
note: SeparationNote {
description: "图标".to_string(),
rework_notes: vec!["不要带父背景".to_string()],
},
children: vec![],
rework_count: 0,
};
let tree = SeparationTree {
src_ui_design: UIDesignImageId::new("page").unwrap(),
root: node.clone(),
root_extractable: true,
};
let separation = SeparationState {
schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(),
trees: vec![tree.clone()],
bound: vec![],
problematic_nodes: vec![],
};
let prompt = super::prompt::gen_extract_prompt(&separation, &tree, &[&node]);
assert!(prompt.contains("previous rework notes:\n- 不要带父背景"));
}
#[test]
fn binding_validation_rejects_overlong_rework_note() {
let node = SeparationNode {
@@ -1,8 +1,45 @@
use crate::ui_editor::commands::separation::model::{SeparationState, SeparationTree};
use crate::ui_editor::commands::separation::{SeparationNode, SeparationNodeKind};
use crate::ui_editor::utils::NodeId;
use serde::Serialize;
use std::collections::HashSet;
use crate::ui_editor::commands::separation::model::{SeparationState, SeparationTree};
use crate::ui_editor::utils::NodeId;
#[derive(Serialize)]
struct ExtractPromptDocument {
ui_layer_tree: ExtractPromptNode,
}
#[derive(Serialize)]
struct ExtractPromptNode {
index: usize,
status: ExtractPromptStatus,
rect: ExtractPromptRect,
description: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
rework_notes: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<ExtractPromptNode>,
}
#[derive(Serialize)]
enum ExtractPromptStatus {
#[serde(rename = "OUTPUT_THIS_TURN")]
OutputThisTurn,
#[serde(rename = "DONE")]
Done,
#[serde(rename = "CONTEXT_ONLY")]
ContextOnly,
#[serde(rename = "REMOVE_ONLY")]
RemoveOnly,
}
#[derive(Serialize)]
struct ExtractPromptRect {
x: u32,
y: u32,
width: u32,
height: u32,
}
pub(crate) fn gen_extract_prompt(
state: &SeparationState,
@@ -36,66 +73,97 @@ pub(crate) fn gen_extract_prompt(
.map(|node| node.node_id.clone()),
)
.collect::<HashSet<_>>();
let mut lines = Vec::new();
let mut index = 1usize;
append_context_lines(
&tree.root,
0,
true,
&target_ids,
&terminal_ids,
&mut index,
&mut lines,
);
result.push_str(&lines.join("\n"));
let mut index = 1;
let document = ExtractPromptDocument {
ui_layer_tree: project_node(&tree.root, &target_ids, &terminal_ids, &mut index),
};
let yaml = serde_yaml::to_string(&document)
.expect("UI separation extract prompt projection must be serializable");
result.push_str("```yaml\n");
result.push_str(&yaml);
result.push_str("```\n");
result
}
fn append_context_lines(
fn project_node(
node: &SeparationNode,
depth: usize,
is_root: bool,
target_ids: &HashSet<NodeId>,
terminal_ids: &HashSet<NodeId>,
index: &mut usize,
lines: &mut Vec<String>,
) {
let status = match node.kind {
SeparationNodeKind::TextRemovalOnly => "REMOVE_ONLY",
SeparationNodeKind::PureContainer => "CONTEXT_ONLY",
SeparationNodeKind::ImageTarget => {
if target_ids.contains(&node.id) {
"OUTPUT_THIS_TURN"
} else if terminal_ids.contains(&node.id) {
"DONE"
} else {
"CONTEXT_ONLY"
}
}
};
let role = if is_root { "root" } else { "child" };
lines.push(format!(
"{}. [{}] depth={} role={} rect=({}, {}, {}, {}) {}",
*index,
status,
depth,
role,
node.global_pos_x_px,
node.global_pos_y_px,
node.width_px,
node.height_px,
node.note.as_prompt()
));
) -> ExtractPromptNode {
let current_index = *index;
*index += 1;
for child in &node.children {
append_context_lines(
child,
depth + 1,
false,
target_ids,
terminal_ids,
index,
lines,
);
let status = match node.kind {
SeparationNodeKind::TextRemovalOnly => ExtractPromptStatus::RemoveOnly,
SeparationNodeKind::PureContainer => ExtractPromptStatus::ContextOnly,
SeparationNodeKind::ImageTarget if target_ids.contains(&node.id) => {
ExtractPromptStatus::OutputThisTurn
}
SeparationNodeKind::ImageTarget if terminal_ids.contains(&node.id) => {
ExtractPromptStatus::Done
}
SeparationNodeKind::ImageTarget => ExtractPromptStatus::ContextOnly,
};
let children = node
.children
.iter()
.map(|child| project_node(child, target_ids, terminal_ids, index))
.collect();
ExtractPromptNode {
index: current_index,
status,
rect: ExtractPromptRect {
x: node.global_pos_x_px,
y: node.global_pos_y_px,
width: node.width_px,
height: node.height_px,
},
description: node.note.description.clone(),
rework_notes: node.note.rework_notes.clone(),
children,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_editor::commands::separation::model::SeparationNote;
#[test]
fn projects_source_node_to_prompt_view() {
let node = SeparationNode {
id: NodeId::new("image").unwrap(),
kind: SeparationNodeKind::ImageTarget,
global_pos_x_px: 1,
global_pos_y_px: 2,
width_px: 3,
height_px: 4,
note: SeparationNote {
description: "按钮".to_string(),
rework_notes: vec!["保留圆角".to_string()],
},
children: vec![],
rework_count: 0,
};
let mut index = 1;
let target_ids = HashSet::from([node.id.clone()]);
let projected = project_node(&node, &target_ids, &HashSet::new(), &mut index);
assert_eq!(projected.index, 1);
assert!(matches!(
projected.status,
ExtractPromptStatus::OutputThisTurn
));
assert_eq!(projected.rect.x, 1);
assert_eq!(projected.rect.y, 2);
assert_eq!(projected.rect.width, 3);
assert_eq!(projected.rect.height, 4);
assert_eq!(projected.description, "按钮");
assert_eq!(projected.rework_notes, vec!["保留圆角".to_string()]);
assert!(projected.children.is_empty());
}
}
@@ -45,6 +45,7 @@ Rust 在接收并校验工具结果后,才把这两个枚举变体映射为正
- image-edit 直接使用原始 UI design PNG,不再生成或发送绿色框、紫色填充等额外辅助输入图。提取 prompt 直接描述完整页面分层清单和当前 batch 状态。
- 追加清单区分本轮 Image 输出目标、已完成 Image、仅作父子/遮挡上下文的 Image,以及只需从父图片移除的 Text。清单使用人类可读的编号、name/description、位置和层级,不向 image-edit 暴露 opaque NodeId。
- 提取 prompt 先把 separation tree 投影为小型 YAML 视图,再注入固定规则文本:每个节点只包含展示编号、状态、`x/y/width/height` 矩形、描述、可选返工意见和递归 childrenYAML 不携带 opaque NodeId,树的嵌套关系替代 `depth/role` 字段。
- 由于 raw endpoint 每次只返回一张 PNGprompt 要求 image-edit 输出透明 atlas:本轮图片层可以移动和缩放,放置在不会互相遮挡的位置;视觉模型返回每层在 processed 图中的实际区域。源节点矩形只用于语义定位,不用于裁切区域推断。
- 请求尺寸始终使用源 UI design 尺寸;Raw GPT Image 2 API 保证返回相同尺寸,客户端不额外做尺寸拒绝检查。
- 处理图解码/写入和 cut 裁切属于本地 CPU/文件操作,放入独立的 `spawn_blocking` 任务;image-edit 与 visual binding 网络请求仍运行在 async future 中。