openpencil/crates/op-orchestrator/src/plan_normalize.rs

415 lines
14 KiB
Rust

//! plan 规范化 —— 单屏路径。
//!
//! 行为忠实 TS,但**一次性算分类、干净派生**,不做 TS 那种
//! in-place strip-then-reclassify(`orchestrator.ts:838-845` 自标
//! fragile)。
use crate::dashboard_columns::{
infer_dashboard_section_height, infer_dashboard_section_width, is_dashboard_like_prompt,
};
use crate::plan::OrchestratorPlan;
use crate::types::DesignRequest;
/// 规范化产出的派生信息。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NormInfo {
/// 根 frame 窄到移动端宽度 —— scaffold 阶段据此注入固定状态栏。
pub is_mobile: bool,
}
/// 移动端宽度上限(含)—— ≤ 此值视为移动端单屏。
const MOBILE_MAX_WIDTH: f64 = 480.0;
/// subtask 的 id / label 命中即视为"状态栏"区块 —— 移动端由
/// scaffold 注入固定状态栏,plan 里若带状态栏 subtask 则剔除。
fn is_status_bar_subtask(id: &str, label: &str) -> bool {
let hay = format!("{} {}", id.to_lowercase(), label.to_lowercase());
hay.contains("status bar") || hay.contains("status-bar") || hay.contains("statusbar")
}
/// 就地规范化 `plan`:
/// - 一次性判定 `is_mobile`(根 frame 宽度);
/// - 移动端剔除 plan 自带的状态栏 subtask(状态栏改由 scaffold 注入);
/// - 给每个 subtask 赋 `id_prefix = id`、`parent_frame_id = 根 id`;
/// - dashboard-like plan:每个 subtask 的 region 用推断宽高覆写
/// (宽度无条件覆写;高度在 [inferred*0.6, inferred*1.6] 窗口内保留
/// LLM 值,超出则取推断值 —— 忠实 TS `normalizeOrchestratorPlan`
/// `orchestrator.ts:259-272`)。
pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo {
let is_mobile = plan.root_frame.width <= MOBILE_MAX_WIDTH;
if is_mobile {
plan.subtasks
.retain(|st| !is_status_bar_subtask(&st.id, &st.label));
}
let root_width = plan.root_frame.width;
let dashboard_like = is_dashboard_like_prompt(&req.prompt, plan);
let root_id = plan.root_frame.id.clone();
for st in &mut plan.subtasks {
st.id_prefix = st.id.clone();
st.parent_frame_id = Some(root_id.clone());
if dashboard_like {
let inferred_width = infer_dashboard_section_width(st, root_width);
let inferred_height = infer_dashboard_section_height(st);
st.region.width = inferred_width;
if st.region.height <= 0.0 {
st.region.height = inferred_height;
} else {
let min_height = (inferred_height * 0.6).round();
let max_height = (inferred_height * 1.6).round();
st.region.height = f64::max(min_height, f64::min(st.region.height, max_height));
}
}
}
NormInfo { is_mobile }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask};
fn req() -> DesignRequest {
DesignRequest {
prompt: "x".into(),
model: None,
provider: None,
design_md: None,
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}
fn subtask(id: &str, label: &str) -> Subtask {
Subtask {
id: id.into(),
label: label.into(),
region: Region {
width: 100.0,
height: 100.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
}
}
fn plan(width: f64, subtasks: Vec<Subtask>) -> OrchestratorPlan {
OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: "P".into(),
width,
height: 800.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks,
style_guide_name: None,
}
}
#[test]
fn normalize_assigns_id_prefix_and_parent() {
let mut p = plan(
1200.0,
vec![subtask("hero", "Hero"), subtask("feat", "Features")],
);
let info = normalize(&mut p, &req());
assert!(!info.is_mobile);
for st in &p.subtasks {
assert_eq!(st.id_prefix, st.id);
assert_eq!(st.parent_frame_id.as_deref(), Some("root"));
}
}
#[test]
fn normalize_flags_mobile_by_width() {
let mut p = plan(390.0, vec![subtask("hero", "Hero")]);
let info = normalize(&mut p, &req());
assert!(info.is_mobile);
}
#[test]
fn normalize_strips_status_bar_subtask_on_mobile() {
let mut p = plan(
390.0,
vec![subtask("status-bar", "Status Bar"), subtask("hero", "Hero")],
);
normalize(&mut p, &req());
assert_eq!(p.subtasks.len(), 1);
assert_eq!(p.subtasks[0].id, "hero");
}
#[test]
fn normalize_keeps_status_bar_subtask_on_desktop() {
// 桌面端不剔除(只有移动端 scaffold 注入固定状态栏)。
let mut p = plan(
1200.0,
vec![subtask("status-bar", "Status Bar"), subtask("hero", "Hero")],
);
normalize(&mut p, &req());
assert_eq!(p.subtasks.len(), 2);
}
// -----------------------------------------------------------------------
// Task C1 — dashboard branch
// -----------------------------------------------------------------------
fn dash_req(prompt: &str) -> DesignRequest {
DesignRequest {
prompt: prompt.into(),
model: None,
provider: None,
design_md: None,
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}
/// Build a dashboard-like plan with a sidebar + two data subtasks.
fn dashboard_plan(root_width: f64) -> OrchestratorPlan {
let st_sidebar = Subtask {
id: "sidebar".into(),
label: "Sidebar Navigation".into(),
region: Region {
width: 100.0,
height: 500.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
};
// chart subtask — LLM-provided height 300, within [inferred*0.6, inferred*1.6]
// inferred for "chart" = 320 → min=192, max=512 → 300 in range → keep 300
let st_chart = Subtask {
id: "revenue-chart".into(),
label: "Revenue Chart".into(),
region: Region {
width: 800.0,
height: 300.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
};
// metric subtask — LLM-provided height 0 (invalid) → use inferred = 160
let st_metric = Subtask {
id: "kpi-metrics".into(),
label: "KPI Metrics".into(),
region: Region {
width: 800.0,
height: 0.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
};
OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: "Dashboard".into(),
width: root_width,
height: 800.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks: vec![st_sidebar, st_chart, st_metric],
style_guide_name: None,
}
}
#[test]
fn normalize_dashboard_rewrites_width_per_infer() {
// root_width = 1200 → main_width = max(320, 1200-260) = 940
// sidebar → 260
// chart/revenue → round(940 * 0.62) = round(582.8) = 583
// metric (default) → 940
let mut p = dashboard_plan(1200.0);
let req = dash_req("an analytics admin dashboard");
normalize(&mut p, &req);
let sidebar = p.subtasks.iter().find(|s| s.id == "sidebar").unwrap();
assert_eq!(sidebar.region.width, 260.0, "sidebar width must be 260");
let chart = p.subtasks.iter().find(|s| s.id == "revenue-chart").unwrap();
let expected_chart_w = (940.0_f64 * 0.62).round();
assert_eq!(
chart.region.width, expected_chart_w,
"chart width = main*0.62"
);
let metric = p.subtasks.iter().find(|s| s.id == "kpi-metrics").unwrap();
assert_eq!(metric.region.width, 940.0, "metric width = full main");
}
#[test]
fn normalize_dashboard_height_kept_when_in_range() {
// chart: inferred=320, LLM=300
// min=round(320*0.6)=192, max=round(320*1.6)=512
// 300 ∈ [192,512] → keep 300
let mut p = dashboard_plan(1200.0);
let req = dash_req("analytics admin dashboard");
normalize(&mut p, &req);
let chart = p.subtasks.iter().find(|s| s.id == "revenue-chart").unwrap();
assert_eq!(
chart.region.height, 300.0,
"LLM height kept when in clamp range"
);
}
#[test]
fn normalize_dashboard_height_replaced_when_zero() {
// metric: LLM height=0 (invalid) → use inferred=160
let mut p = dashboard_plan(1200.0);
let req = dash_req("data analytics admin dashboard");
normalize(&mut p, &req);
let metric = p.subtasks.iter().find(|s| s.id == "kpi-metrics").unwrap();
assert_eq!(
metric.region.height, 160.0,
"zero LLM height replaced by inferred"
);
}
#[test]
fn normalize_dashboard_height_clamped_to_max() {
// "customer-table" subtask: id+label match "table" (no "transaction"),
// so inferred = 340 → max=round(340*1.6)=544
// max(round(340*0.6), min(2000, 544)) = max(204, 544) = 544
let mut p = OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: "Dashboard".into(),
width: 1200.0,
height: 800.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks: vec![Subtask {
id: "customer-table".into(),
label: "Customer Table".into(),
region: Region {
width: 800.0,
height: 2000.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
}],
style_guide_name: None,
};
normalize(&mut p, &dash_req("data analytics admin dashboard"));
assert_eq!(
p.subtasks[0].region.height,
(340.0_f64 * 1.6).round(),
"over-tall LLM height clamped to max"
);
}
#[test]
fn normalize_dashboard_height_clamped_to_min() {
// chart subtask with too-short LLM height (50)
// inferred=320 → min=round(320*0.6)=192, max=round(320*1.6)=512
// max(192, min(50, 512)) = max(192, 50) = 192
let mut p = OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: "Dashboard".into(),
width: 1200.0,
height: 800.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks: vec![Subtask {
id: "rev-chart".into(),
label: "Revenue Chart".into(),
region: Region {
width: 800.0,
height: 50.0,
},
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
}],
style_guide_name: None,
};
normalize(&mut p, &dash_req("data analytics admin dashboard"));
assert_eq!(
p.subtasks[0].region.height,
(320.0_f64 * 0.6).round(),
"too-short LLM height clamped to min"
);
}
#[test]
fn normalize_non_dashboard_plan_is_unaffected() {
// A landing page prompt — NOT dashboard-like — must not rewrite regions.
let mut p = plan(
1200.0,
vec![
subtask("hero", "Hero Section"),
subtask("features", "Feature Cards"),
],
);
// Give them non-zero regions so default-fill doesn't fire.
p.subtasks[0].region = Region {
width: 1200.0,
height: 560.0,
};
p.subtasks[1].region = Region {
width: 1200.0,
height: 400.0,
};
normalize(
&mut p,
&dash_req("a beautiful landing page for a SaaS product"),
);
assert_eq!(p.subtasks[0].region.width, 1200.0);
assert_eq!(p.subtasks[0].region.height, 560.0);
assert_eq!(p.subtasks[1].region.width, 1200.0);
assert_eq!(p.subtasks[1].region.height, 400.0);
}
}