fix(agent): require navigation on primary mobile screens
This commit is contained in:
parent
b4322f6d2b
commit
1ae53fa615
59
crates/op-orchestrator/src/plan_home_intent.rs
Normal file
59
crates/op-orchestrator/src/plan_home_intent.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Classify mobile app roots whose bottom tab bar is structural anatomy.
|
||||
|
||||
use crate::plan::OrchestratorPlan;
|
||||
|
||||
/// A multi-section mobile plan whose root frame is named like an app
|
||||
/// home/main/feed screen — the shape whose bottom tab bar is mandatory.
|
||||
pub(super) fn plan_is_app_home_screen(plan: &OrchestratorPlan) -> bool {
|
||||
if plan.subtasks.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let name = normalize_screen_name(&plan.root_frame.name);
|
||||
let secondary_flow = [
|
||||
"detail",
|
||||
"details",
|
||||
"form",
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"sign up",
|
||||
"signup",
|
||||
"register",
|
||||
"checkout",
|
||||
"onboarding",
|
||||
"confirmation",
|
||||
"success",
|
||||
"wizard",
|
||||
"buy now",
|
||||
]
|
||||
.iter()
|
||||
.any(|phrase| screen_name_has_phrase(&name, phrase));
|
||||
if secondary_flow {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preserve the legacy substring behavior for established primary-screen
|
||||
// markers. Planner names commonly compact these into HomeScreen,
|
||||
// Newsfeed, Discovery, or Browser.
|
||||
["home", "feed", "discover", "browse", "dashboard"]
|
||||
.iter()
|
||||
.any(|marker| name.contains(marker))
|
||||
|| ["main screen", "now screen"]
|
||||
.iter()
|
||||
.any(|phrase| screen_name_has_phrase(&name, phrase))
|
||||
}
|
||||
|
||||
fn normalize_screen_name(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
.split(|ch: char| !ch.is_alphanumeric())
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn screen_name_has_phrase(name: &str, phrase: &str) -> bool {
|
||||
let padded_name = format!(" {name} ");
|
||||
let padded_phrase = format!(" {phrase} ");
|
||||
padded_name.contains(&padded_phrase)
|
||||
}
|
||||
|
|
@ -10,12 +10,20 @@ use crate::dashboard_columns::{
|
|||
use crate::plan::{OrchestratorPlan, Region, Subtask};
|
||||
use crate::types::DesignRequest;
|
||||
|
||||
#[path = "plan_home_intent.rs"]
|
||||
mod plan_home_intent;
|
||||
use plan_home_intent::plan_is_app_home_screen;
|
||||
|
||||
// multiscreen-fanout-break fix (item A) — screen-grouping tests, split out
|
||||
// to keep this file's inline `mod tests` from crossing the 800-line cap.
|
||||
#[cfg(test)]
|
||||
#[path = "plan_normalize_screen_groups_tests.rs"]
|
||||
mod tests_screen_groups;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plan_normalize_nav_tests.rs"]
|
||||
mod tests_nav;
|
||||
|
||||
/// 规范化产出的派生信息。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NormInfo {
|
||||
|
|
@ -59,12 +67,7 @@ fn strip_status_bar_fragments(text: &str) -> Option<String> {
|
|||
|
||||
fn prompt_requests_bottom_nav(prompt: &str) -> bool {
|
||||
let hay = prompt.to_lowercase();
|
||||
let negated = hay.contains("no bottom nav")
|
||||
|| hay.contains("without bottom nav")
|
||||
|| hay.contains("without bottom navigation")
|
||||
|| hay.contains("不要底部导航")
|
||||
|| hay.contains("不需要底部导航");
|
||||
if negated {
|
||||
if prompt_forbids_bottom_nav(prompt) {
|
||||
return false;
|
||||
}
|
||||
hay.contains("bottom nav")
|
||||
|
|
@ -77,6 +80,15 @@ fn prompt_requests_bottom_nav(prompt: &str) -> bool {
|
|||
|| hay.contains("底栏")
|
||||
}
|
||||
|
||||
fn prompt_forbids_bottom_nav(prompt: &str) -> bool {
|
||||
let hay = prompt.to_lowercase();
|
||||
hay.contains("no bottom nav")
|
||||
|| hay.contains("without bottom nav")
|
||||
|| hay.contains("without bottom navigation")
|
||||
|| hay.contains("不要底部导航")
|
||||
|| hay.contains("不需要底部导航")
|
||||
}
|
||||
|
||||
fn is_bottom_nav_subtask(st: &Subtask) -> bool {
|
||||
let hay = format!(
|
||||
"{} {} {}",
|
||||
|
|
@ -95,6 +107,10 @@ fn is_bottom_nav_subtask(st: &Subtask) -> bool {
|
|||
}
|
||||
|
||||
fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &DesignRequest) {
|
||||
if prompt_forbids_bottom_nav(&req.prompt) {
|
||||
plan.subtasks.retain(|st| !is_bottom_nav_subtask(st));
|
||||
return;
|
||||
}
|
||||
if plan.subtasks.iter().any(is_bottom_nav_subtask) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -129,25 +145,6 @@ fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &Design
|
|||
});
|
||||
}
|
||||
|
||||
/// A multi-section mobile plan whose root frame is named like an app
|
||||
/// home/main/feed screen — the shape whose bottom tab bar is mandatory.
|
||||
fn plan_is_app_home_screen(plan: &OrchestratorPlan) -> bool {
|
||||
if plan.subtasks.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let name = plan.root_frame.name.to_lowercase();
|
||||
[
|
||||
"home",
|
||||
"main screen",
|
||||
"feed",
|
||||
"discover",
|
||||
"browse",
|
||||
"dashboard",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| name.contains(k))
|
||||
}
|
||||
|
||||
/// 就地规范化 `plan`:
|
||||
/// - 一次性判定 `is_mobile`(根 frame 宽度);
|
||||
/// - 移动端剔除 plan 自带的状态栏 subtask(状态栏改由 scaffold 注入);
|
||||
|
|
|
|||
219
crates/op-orchestrator/src/plan_normalize_nav_tests.rs
Normal file
219
crates/op-orchestrator/src/plan_normalize_nav_tests.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
use super::*;
|
||||
use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask};
|
||||
|
||||
fn request(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,
|
||||
}
|
||||
}
|
||||
|
||||
fn section(id: &str, label: &str) -> Subtask {
|
||||
Subtask {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
region: Region {
|
||||
width: 375.0,
|
||||
height: 120.0,
|
||||
},
|
||||
id_prefix: String::new(),
|
||||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: None,
|
||||
generated_root_id: None,
|
||||
existing_section_labels: None,
|
||||
retry_feedback: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_plan(name: &str, labels: &[(&str, &str)]) -> OrchestratorPlan {
|
||||
OrchestratorPlan {
|
||||
root_frame: RootFrameSpec {
|
||||
id: "root".into(),
|
||||
name: name.into(),
|
||||
width: 375.0,
|
||||
height: 812.0,
|
||||
layout: None,
|
||||
gap: None,
|
||||
padding: None,
|
||||
fill: None,
|
||||
},
|
||||
subtasks: labels
|
||||
.iter()
|
||||
.map(|(id, label)| section(id, label))
|
||||
.collect(),
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_bottom_nav(plan: &OrchestratorPlan) -> bool {
|
||||
plan.subtasks.iter().any(is_bottom_nav_subtask)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meteo_now_screen_gets_bottom_navigation_backstop() {
|
||||
let mut plan = mobile_plan(
|
||||
"Meteo - Now Screen",
|
||||
&[
|
||||
("hero", "Header & Main Temperature Hero"),
|
||||
("telemetry", "Telemetry & Wind Grid"),
|
||||
("hourly", "Hourly Forecast Strip"),
|
||||
("forecast", "7-Day Forecast List"),
|
||||
("sun-arc", "Sunrise & Sunset Arc"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(
|
||||
&mut plan,
|
||||
&request("Design the Now screen for the Meteo weather app"),
|
||||
);
|
||||
|
||||
assert!(has_bottom_nav(&plan));
|
||||
let nav = plan
|
||||
.subtasks
|
||||
.last()
|
||||
.expect("navbar should be appended last");
|
||||
assert_eq!(nav.id, "bottom-navigation");
|
||||
assert_eq!(nav.parent_frame_id.as_deref(), Some("root"));
|
||||
assert_eq!(nav.region.width, 375.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_screen_does_not_get_implicit_bottom_navigation() {
|
||||
let mut plan = mobile_plan(
|
||||
"Meteo - Forecast Detail Screen",
|
||||
&[
|
||||
("header", "Forecast Header"),
|
||||
("chart", "Precipitation Chart"),
|
||||
("metrics", "Weather Metrics"),
|
||||
("advisory", "Weather Advisory"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(&mut plan, &request("Design a forecast detail screen"));
|
||||
|
||||
assert!(!has_bottom_nav(&plan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_flow_does_not_get_nav_even_when_name_mentions_main_screen() {
|
||||
let mut plan = mobile_plan(
|
||||
"Account Form - Main Screen",
|
||||
&[
|
||||
("header", "Account Header"),
|
||||
("fields", "Profile Fields"),
|
||||
("preferences", "Preferences"),
|
||||
("actions", "Save Actions"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(&mut plan, &request("Design a mobile account form"));
|
||||
|
||||
assert!(!has_bottom_nav(&plan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_name_matching_uses_word_boundaries() {
|
||||
let mut plan = mobile_plan(
|
||||
"Performance Screen",
|
||||
&[
|
||||
("summary", "Summary"),
|
||||
("chart", "Trend Chart"),
|
||||
("metrics", "Metrics"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(&mut plan, &request("Design a performance screen"));
|
||||
|
||||
assert!(!has_bottom_nav(&plan), "form must not match performance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_compound_primary_screen_names_keep_bottom_navigation() {
|
||||
for name in [
|
||||
"FoodHomeScreen",
|
||||
"Community Newsfeed",
|
||||
"Discovery Screen",
|
||||
"In-App Browser",
|
||||
] {
|
||||
let mut plan = mobile_plan(
|
||||
name,
|
||||
&[
|
||||
("header", "Header"),
|
||||
("content", "Primary Content"),
|
||||
("summary", "Summary"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(&mut plan, &request("Design a primary mobile app screen"));
|
||||
|
||||
assert!(
|
||||
has_bottom_nav(&plan),
|
||||
"legacy primary-screen marker should still match: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_now_screen_does_not_get_implicit_bottom_navigation() {
|
||||
let mut plan = mobile_plan(
|
||||
"Buy Now Screen",
|
||||
&[
|
||||
("summary", "Order Summary"),
|
||||
("payment", "Payment Method"),
|
||||
("actions", "Purchase Actions"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(&mut plan, &request("Design a Buy Now purchase flow"));
|
||||
|
||||
assert!(!has_bottom_nav(&plan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_no_nav_request_overrides_primary_screen_backstop() {
|
||||
let mut plan = mobile_plan(
|
||||
"Meteo - Now Screen",
|
||||
&[
|
||||
("hero", "Temperature Hero"),
|
||||
("hourly", "Hourly Forecast"),
|
||||
("forecast", "7-Day Forecast"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(
|
||||
&mut plan,
|
||||
&request("Design the Meteo Now screen without bottom navigation"),
|
||||
);
|
||||
|
||||
assert!(!has_bottom_nav(&plan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_no_nav_request_removes_planned_bottom_navigation() {
|
||||
let mut plan = mobile_plan(
|
||||
"Meteo - Now Screen",
|
||||
&[
|
||||
("hero", "Temperature Hero"),
|
||||
("hourly", "Hourly Forecast"),
|
||||
("forecast", "7-Day Forecast"),
|
||||
("bottom-nav", "Bottom Navigation"),
|
||||
],
|
||||
);
|
||||
|
||||
normalize(
|
||||
&mut plan,
|
||||
&request("Design the Meteo Now screen with no bottom nav"),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!has_bottom_nav(&plan),
|
||||
"an explicit no-nav instruction must override the model plan"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue