diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index 5f94896aa..17c87f65c 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -35,5 +35,6 @@ pub use design_md_policy::{ pub use design_type::{detect_design_type, DesignType, DesignTypePreset}; pub use intent::classify_intent; pub use model_profile::{resolve_model_profile, ModelProfile, ModelTier}; +pub use prompt::build_orchestrator_prompt; pub use run::Orchestrator; pub use types::*; diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 3bafa022d..60ef1673e 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -10,28 +10,17 @@ //! 的细则(style-guide 注入、移动端禁 phone-wrapper 等)是后续 //! 细化项。 +use crate::compact_prompt::build_compact_planning_prompt; +use crate::design_type::{detect_design_type, DesignType}; use crate::plan::{OrchestratorPlan, Subtask}; -use crate::types::{AbortFlag, CallRequest, DesignRequest}; +use crate::style_guide_context::build_planning_style_guide_context; +use crate::types::{AbortFlag, CallRequest, DesignRequest, PlanningMode, PlanningPrompt}; +use std::collections::HashMap; use std::time::Duration; const PLANNING_TIMEOUT: Duration = Duration::from_secs(300); const SUBAGENT_TIMEOUT: Duration = Duration::from_secs(420); -/// 规划阶段要求模型产出的 JSON 形状说明。 -const PLAN_FORMAT: &str = r##" -Respond with a single JSON object describing the design plan: -{ - "rootFrame": { "id": "root", "name": "", "width": , "height": , - "layout": "vertical", "gap": , - "fill": [{ "type": "solid", "color": "#RRGGBB" }] }, - "styleGuideName": "", - "subtasks": [ - { "id": "", "label": "", - "region": { "width": , "height": } } - ] -} -Each subtask is one visual section. Use 1-6 subtasks. Output ONLY the JSON object."##; - /// sub-agent 阶段要求模型产出的 JSON 形状说明。 const NODE_FORMAT: &str = r#" Respond with a JSON array of canonical PenNode objects for THIS section only. @@ -43,6 +32,103 @@ ALL field names are camelCase: cornerRadius, fontSize, fontWeight, justifyConten alignItems, clipContent. Geometry fields are x, y, width, height. Never snake_case. Output ONLY the JSON array."#; +/// Rich 模式 system prompt 末尾后缀 —— verbatim,`orchestrator.ts:1382-1383`。 +const RICH_SUFFIX: &str = "\n\n---\nCRITICAL OUTPUT FORMAT ENFORCEMENT:\n\ +You MUST output ONLY a single JSON object. Start your response with { and end with }.\n\ +Do NOT output any text, explanation, analysis, markdown, or tool calls before or after the JSON.\n\ +Do NOT \"explore\" or \"think out loud\". Do NOT use or function calls.\n\ +Any pre-design analysis (concept extraction, superfan simulation, etc.) must happen internally — include results as JSON fields, never as prose.\n\ +Violating this format will cause a system error."; + +/// Minimal 模式后缀 —— verbatim,`orchestrator.ts:1384`。 +const MINIMAL_SUFFIX: &str = + "\n\nOUTPUT ONLY ONE JSON OBJECT. No prose. No markdown. No tool calls."; + +fn planning_suffix(mode: PlanningMode) -> &'static str { + match mode { + PlanningMode::Rich => RICH_SUFFIX, + PlanningMode::Minimal => MINIMAL_SUFFIX, + PlanningMode::Compact => "", + } +} + +/// 丢掉 `landing-page-predesign` skill(除非设计类型是 landing-page)。 +/// 见 spec §5.10。 +fn filter_planning_skills_for_prompt( + skills: Vec, + prompt: &str, +) -> Vec { + if detect_design_type(prompt).type_ == DesignType::LandingPage { + return skills; + } + skills + .into_iter() + .filter(|s| s.meta.name != "landing-page-predesign") + .collect() +} + +/// 规划阶段的 LLM 调用输入。`mode` 决定 prompt 构造方式;返回 +/// `PlanningPrompt`(带 compact 的 forced style-guide 名,供 S3b-1b)。 +pub fn build_orchestrator_prompt( + req: &DesignRequest, + mode: PlanningMode, + abort: AbortFlag, +) -> PlanningPrompt { + match mode { + PlanningMode::Compact => { + let cp = build_compact_planning_prompt(&req.prompt, req.design_md.as_ref()); + PlanningPrompt { + call_request: CallRequest { + system_prompt: cp.system, + user_prompt: cp.user_prompt, + model: req.model.clone(), + provider: req.provider.clone(), + timeout: PLANNING_TIMEOUT, + abort, + }, + forced_style_guide_name: Some(cp.selected_style_guide_name), + mode, + } + } + PlanningMode::Rich | PlanningMode::Minimal => { + let ctx = build_planning_style_guide_context( + &req.prompt, + req.model.as_deref(), + mode, + req.design_md.as_ref(), + ); + let opts = op_ai_skills::ResolveOptions { + dynamic_content: HashMap::from([( + "availableStyleGuides".to_string(), + ctx.available_style_guides, + )]), + ..Default::default() + }; + let agent_ctx = + op_ai_skills::resolve_skills(op_ai_skills::Phase::Planning, &req.prompt, &opts); + let skills = filter_planning_skills_for_prompt(agent_ctx.skills, &req.prompt); + let mut system_prompt = skills + .iter() + .map(|s| s.content.as_str()) + .collect::>() + .join("\n\n"); + system_prompt.push_str(planning_suffix(mode)); + PlanningPrompt { + call_request: CallRequest { + system_prompt, + user_prompt: req.prompt.clone(), + model: req.model.clone(), + provider: req.provider.clone(), + timeout: PLANNING_TIMEOUT, + abort, + }, + forced_style_guide_name: None, + mode, + } + } + } +} + /// 把解析出的 skill 正文 join 成一段。 fn skill_preamble(phase: op_ai_skills::Phase, message: &str) -> String { let ctx = @@ -54,21 +140,6 @@ fn skill_preamble(phase: op_ai_skills::Phase, message: &str) -> String { .join("\n\n") } -/// 规划阶段的 LLM 调用输入。 -pub fn build_orchestrator_prompt(req: &DesignRequest, abort: AbortFlag) -> CallRequest { - let mut system_prompt = skill_preamble(op_ai_skills::Phase::Planning, &req.prompt); - system_prompt.push_str("\n\n"); - system_prompt.push_str(PLAN_FORMAT); - CallRequest { - system_prompt, - user_prompt: req.prompt.clone(), - model: req.model.clone(), - provider: req.provider.clone(), - timeout: PLANNING_TIMEOUT, - abort, - } -} - /// 单个 sub-agent 的 LLM 调用输入。 pub fn build_subagent_prompt( subtask: &Subtask, @@ -128,11 +199,49 @@ mod tests { } #[test] - fn orchestrator_prompt_carries_request_and_format() { - let cr = build_orchestrator_prompt(&req(), AbortFlag::new()); - assert!(cr.user_prompt.contains("a pricing page")); - assert!(cr.system_prompt.contains("subtasks")); - assert_eq!(cr.model.as_deref(), Some("claude")); + fn rich_prompt_has_style_guides_and_suffix() { + let pp = build_orchestrator_prompt(&req(), PlanningMode::Rich, AbortFlag::new()); + assert_eq!(pp.mode, PlanningMode::Rich); + assert!(pp.forced_style_guide_name.is_none()); + // style-guide context 经 {{availableStyleGuides}} 注入到 planning skill + assert!(pp + .call_request + .system_prompt + .contains("Available style guides")); + // rich 后缀 + assert!(pp + .call_request + .system_prompt + .contains("CRITICAL OUTPUT FORMAT ENFORCEMENT")); + assert_eq!(pp.call_request.user_prompt, req().prompt); + } + + #[test] + fn minimal_prompt_has_short_suffix_no_snippets() { + let pp = build_orchestrator_prompt(&req(), PlanningMode::Minimal, AbortFlag::new()); + assert!(pp + .call_request + .system_prompt + .contains("OUTPUT ONLY ONE JSON OBJECT")); + assert!(!pp + .call_request + .system_prompt + .contains("CRITICAL OUTPUT FORMAT ENFORCEMENT")); + } + + #[test] + fn compact_prompt_carries_forced_guide_name() { + let pp = build_orchestrator_prompt(&req(), PlanningMode::Compact, AbortFlag::new()); + assert!(pp.forced_style_guide_name.is_some()); + assert!(pp + .call_request + .system_prompt + .starts_with("You are a UI planning assistant.")); + // compact 不带 rich/minimal 后缀 + assert!(!pp + .call_request + .system_prompt + .contains("CRITICAL OUTPUT FORMAT ENFORCEMENT")); } #[test] diff --git a/crates/op-orchestrator/src/run.rs b/crates/op-orchestrator/src/run.rs index 0adddd720..ced1bda48 100644 --- a/crates/op-orchestrator/src/run.rs +++ b/crates/op-orchestrator/src/run.rs @@ -11,8 +11,8 @@ use crate::prompt::build_orchestrator_prompt; use crate::scaffold::build_scaffold; use crate::subagent::run_subtask; use crate::types::{ - AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError, Progress, - RunSummary, SubtaskOutcome, + AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError, PlanningMode, + Progress, RunSummary, SubtaskOutcome, }; use crate::variables::{rollback, seed_commands, snapshot_plan_vars}; use futures::StreamExt; @@ -39,7 +39,8 @@ impl Orchestrator { ) -> Result { // -- 阶段 1:规划 -- on_progress(Progress::Planning); - let plan_call = build_orchestrator_prompt(&request, abort.clone()); + let plan_call = + build_orchestrator_prompt(&request, PlanningMode::Rich, abort.clone()).call_request; let mut plan = match collect_text(llm.call(plan_call)).await { Ok(text) => parse_plan(&text).unwrap_or_else(|_| build_fallback_plan(&request)), Err(aborted) => { diff --git a/crates/op-orchestrator/src/style_guide_context.rs b/crates/op-orchestrator/src/style_guide_context.rs index 4a821489d..468824547 100644 --- a/crates/op-orchestrator/src/style_guide_context.rs +++ b/crates/op-orchestrator/src/style_guide_context.rs @@ -1,9 +1,6 @@ //! 规划 prompt 的 style-guide 上下文构造 —— port of //! `orchestrator-prompt-optimizer.ts` 的 catalog 路径。 -// Functions are pub(crate); callers added in B3/B4/B5. -#![allow(dead_code)] - use crate::design_md_policy::{ build_design_md_style_policy, guess_neutral_background_from_theme, infer_design_md_background, }; @@ -405,9 +402,17 @@ pub(crate) fn format_guide_snippet(guide: &ParsedStyleGuide) -> String { #[derive(Debug, Clone)] pub(crate) struct PlanningStyleGuideContext { pub available_style_guides: String, + /// 诊断字段 —— tests + S3b-1b 消费;production 暂未读取。 + #[allow(dead_code)] pub metadata_count: usize, + /// 诊断字段 —— tests + S3b-1b 消费;production 暂未读取。 + #[allow(dead_code)] pub snippet_count: usize, + /// 诊断字段 —— tests + S3b-1b 消费;production 暂未读取。 + #[allow(dead_code)] pub top_guide_names: Vec, + /// 诊断字段 —— tests + S3b-1b 消费;production 暂未读取。 + #[allow(dead_code)] pub snippet_guide_names: Vec, } diff --git a/crates/op-orchestrator/src/types.rs b/crates/op-orchestrator/src/types.rs index 7ca350e92..7a0087841 100644 --- a/crates/op-orchestrator/src/types.rs +++ b/crates/op-orchestrator/src/types.rs @@ -85,6 +85,16 @@ pub enum PlanningMode { Compact, } +/// `build_orchestrator_prompt` 的产物 —— 比裸 `CallRequest` 多带 +/// compact 模式的 `forced_style_guide_name`(S3b-1b 回填 plan 用)。 +#[derive(Debug, Clone)] +pub struct PlanningPrompt { + pub call_request: CallRequest, + /// compact 模式预选的 styleGuideName;rich/minimal 为 None。 + pub forced_style_guide_name: Option, + pub mode: PlanningMode, +} + /// 用户消息的意图分类 —— 决定走编排器还是普通聊天。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Intent {