From 70ecfc6f6a5d044f09534ef6527091657b9065fe Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 6 Jun 2026 15:43:42 +0800 Subject: [PATCH] fix(ai): harden node parser for weak-model output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weak models emit dirty node JSON that previously failed whole subtasks. Re-nest flat `_parent` output into a tree (port of TS parseJsonlToTree; horizontal containers were collapsing to vertical), strip reasoning `` blocks (including truncated ones) so drafts aren't parsed as nodes, resolve documented numeric design tokens (`$type`/`$spacing`/ `$radius`) and wrap bare `fill` color strings that canonical PenNode rejects, and restore per-node tolerance — re-nesting had collapsed the flat array so one bad deep node failed the entire root. Split the test module into sibling parse_tests.rs to keep parse.rs under the 800-line cap; ark_metrics.jsonl is the captured regression. --- crates/op-orchestrator/src/parse.rs | 453 +++++++++++------- crates/op-orchestrator/src/parse_tests.rs | 330 +++++++++++++ .../src/test_fixtures/ark_metrics.jsonl | 31 ++ 3 files changed, 643 insertions(+), 171 deletions(-) create mode 100644 crates/op-orchestrator/src/parse_tests.rs create mode 100644 crates/op-orchestrator/src/test_fixtures/ark_metrics.jsonl diff --git a/crates/op-orchestrator/src/parse.rs b/crates/op-orchestrator/src/parse.rs index adfd05525..1529209bc 100644 --- a/crates/op-orchestrator/src/parse.rs +++ b/crates/op-orchestrator/src/parse.rs @@ -27,6 +27,11 @@ impl std::fmt::Display for ParseError { /// children 2),于是无需任何 colon/label 启发式,带标签的真数组也不会被误跳。 /// 全部候选失败 / 无候选视为错误。 pub fn parse_nodes(text: &str) -> Result, ParseError> { + // 推理模型(MiniMax-M3 / DeepSeek-R 系等)先吐 `…` 再给真正 + // 答案,且 think 里常含**未完成的草稿 JSON**(扁平、带 `//` 注释)。先剥到 + // 最后一个 `` 之后,避免把草稿误当输出(实测 M3 否则被扫出扁平 + // depth-2 残骸)。无闭合标签(在 think 中被 max_tokens 截断)则用原文。 + let text = strip_reasoning(text); let mut best: Option> = None; let mut best_total = 0usize; let mut last_err: Option = None; @@ -56,6 +61,33 @@ pub fn parse_nodes(text: &str) -> Result, ParseError> { }) } +/// 剥掉推理模型的 `…`,返回真正答案部分。两步: +/// 1. **跳过所有已闭合 think 块**:取最后一个 `` / `` 之后的文本。 +/// 2. 在其余文本里,**截掉任何仍未闭合的 `` 块**(被 `max_tokens` 截断的 +/// 末尾 think):返回该开标签**之前**的部分。 +/// +/// 这样无论 think 在开头、还是"闭合块 + 内容 + 末尾被截断的第二个 think 块" +/// (Codex review 指出的二段式),都不会把未完成的 think 草稿当真节点解析。 +/// 无 think 标签 → 原样返回(非推理模型安全);末尾截断 → 通常返回空 → 解析失败重试。 +fn strip_reasoning(text: &str) -> &str { + // 1) 跳过所有闭合块。 + let after_closed = ["", ""] + .iter() + .filter_map(|tag| text.rfind(tag).map(|i| i + tag.len())) + .max() + .map(|i| &text[i..]) + .unwrap_or(text); + // 2) 截掉残留的未闭合 think 块。 + match ["", ""] + .iter() + .filter_map(|tag| after_closed.find(tag)) + .min() + { + Some(start) => &after_closed[..start], + None => after_closed, + } +} + /// 递归总节点数(含后代),复用 cleanup 的后代计数。用于在候选间取"最完整" /// 的解析结果。 fn total_nodes(nodes: &[PenNode]) -> usize { @@ -66,45 +98,189 @@ fn total_nodes(nodes: &[PenNode]) -> usize { .sum::() } -/// JSONL / bare-object 收集:把每个顶层 `{...}` 解析为一个 `PenNode`(非节点 -/// 对象——如 `{"nodes":[…]}` 这种无 `type` 的包裹——被跳过)。返回全部成功 -/// 解析的顶层节点;由 [`parse_nodes`] 与数组形态按总节点数比较择优。 +/// JSONL / bare-object 收集:取所有顶层 `{...}`,按 `_parent` 重组成树(扁平 +/// `_parent` 格式——TS prompt 要求、DeepSeek 用的 JSONL 形态),再逐根反序列化。 +/// 非节点对象(`{"nodes":[…]}` 包裹、散落的 `{type:solid}` 等)在重组阶段被 +/// [`is_node_object`] 滤掉。由 [`parse_nodes`] 与数组形态按总节点数比较择优。 fn collect_bare_objects(text: &str) -> Vec { - let mut nodes = Vec::new(); - for obj in balanced_spans(text, b'{', b'}') { - let Ok(mut value) = serde_json::from_str::(obj) else { - continue; - }; - normalize_generated_node_json(&mut value); - if let Ok(node) = serde_json::from_value::(value) { - nodes.push(node); - } - } - nodes + let items: Vec = balanced_spans(text, b'{', b'}') + .into_iter() + .filter_map(|obj| serde_json::from_str(obj).ok()) + .collect(); + deserialize_roots(renest_by_parent(items)).unwrap_or_default() } -/// 走完整 parse 管线解析单个候选 `[...]`:serde `Value` → normalize → -/// **逐元素**反序列化为 `Vec`。 -/// -/// 弱模型常在节点数组里混入个别坏元素(例如把 fill 的 -/// `{"type":"solid"}` 当成节点写进数组)。老的整组 -/// `from_value::>` 只要一个元素坏就丢掉**整段子任务** -/// (实测 Dashboard charts-row 因此 0 节点、后续 table-section 也被 -/// 中断)。这里改为逐元素反序列化:保留有效节点、跳过并记录坏的。 -/// 全有效时结果与老逻辑一致(严格不回退);全坏 / 空数组返回 `Err` -/// 以便上层试下一个候选。 +/// 解析单个候选 `[...]`:serde `Value::Array` → 按 `_parent` 重组成树 → +/// 逐根反序列化为 `Vec`。 fn try_parse_candidate(json: &str) -> Result, ParseError> { - let mut value: serde_json::Value = + let value: serde_json::Value = serde_json::from_str(json).map_err(|e| ParseError(format!("json: {e}")))?; - normalize_generated_node_json(&mut value); let serde_json::Value::Array(items) = value else { return Err(ParseError("candidate is not a JSON array".into())); }; - let total = items.len(); + deserialize_roots(renest_by_parent(items)) +} + +/// canonical `PenNode` 的 `type` 标签全集。重组阶段据此过滤掉非节点对象 +/// (散落的 `{type:solid}` 字段对象、`{"nodes":[…]}` 包裹等)——既是弱模型 +/// 容错(坏元素不毁整段),也避免把它们当节点重组进树。 +const NODE_KINDS: &[&str] = &[ + "frame", + "group", + "rectangle", + "ellipse", + "line", + "polygon", + "path", + "text", + "text_input", + "image", + "icon_font", + "ref", +]; + +fn is_node_object(value: &serde_json::Value) -> bool { + value + .get("type") + .and_then(serde_json::Value::as_str) + .is_some_and(|ty| NODE_KINDS.contains(&ty)) +} + +/// 把扁平节点列表按 `_parent` 重组成嵌套树(移植 TS +/// `design-parser.ts::parseJsonlToTree`)。 +/// +/// TS 的 sub-agent prompt 要求**扁平 `_parent` 格式**(每节点一行、`_parent` +/// 指父 id、root 为 `null`),TS 解析时据此建树。Rust 此前漏了这步——扁平 +/// 节点被直接插成兄弟,横向容器的子项全跑到 root 下 → 布局竖排破损。M2.7 / +/// DeepSeek 都用这个格式;方舟用嵌套 children。 +/// +/// 行为:先用 [`is_node_object`] 滤掉非节点对象;若没有任何 `_parent` 字段, +/// 原样返回(嵌套 / 纯 root 形态);否则按出现顺序建 id→对象 + 父→子表,从 +/// root(`_parent:null` 或父不存在的孤儿)递归装配。已取走的 id 不再装配(防环)。 +fn renest_by_parent(items: Vec) -> Vec { + use std::collections::HashMap; + let mut by_id: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + let mut parent_of: HashMap> = HashMap::new(); + let mut seen_parent = false; + let mut dropped = 0usize; + for mut item in items { + if !is_node_object(&item) { + continue; + } + let Some(obj) = item.as_object_mut() else { + continue; + }; + let id = match obj.get("id").and_then(serde_json::Value::as_str) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + let parent_id = match obj.remove("_parent") { + Some(serde_json::Value::String(p)) if !p.is_empty() => { + seen_parent = true; + Some(p) + } + Some(_) => { + seen_parent = true; // null / anything else → root + None + } + None => None, + }; + if by_id.contains_key(&id) { + continue; // duplicate id — keep the first + } + // Per-node tolerance (restored after re-nesting): a node whose OWN + // fields still don't deserialize after normalize would otherwise fail + // its whole assembled root (re-nesting collapsed the flat array, so the + // per-element tolerance the array path used to have was lost). Probe + // each as a childless leaf and drop the unsalvageable ones, keeping the + // rest of the section instead of losing the entire subtask. + let mut probe = item.clone(); + if let Some(po) = probe.as_object_mut() { + po.remove("children"); + } + normalize_generated_node_json(&mut probe); + if serde_json::from_value::(probe).is_err() { + dropped += 1; + continue; + } + order.push(id.clone()); + parent_of.insert(id.clone(), parent_id); + by_id.insert(id, item); + } + if dropped > 0 { + eprintln!("[parse] renest dropped {dropped} node(s) failing schema validation"); + } + if !seen_parent { + // 没有 `_parent`:已是嵌套 / 纯 root 形态,原样返回(保持顺序)。 + return order + .into_iter() + .filter_map(|id| by_id.remove(&id)) + .collect(); + } + let mut children_of: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + for id in &order { + match parent_of.get(id).cloned().flatten() { + Some(parent) if parent != *id && by_id.contains_key(&parent) => { + children_of.entry(parent).or_default().push(id.clone()); + } + _ => roots.push(id.clone()), // root / 孤儿 / 自指 + } + } + let mut out: Vec = roots + .into_iter() + .filter_map(|id| assemble(&id, &mut by_id, &children_of)) + .collect(); + // 无丢失保证:若有节点不可从任何 root 到达(纯 `_parent` 环),按原始顺序 + // 把剩下的提升为 root 装配出来,不静默丢节点(环会被 assemble 的 take 打断)。 + for id in &order { + if by_id.contains_key(id) { + if let Some(node) = assemble(id, &mut by_id, &children_of) { + out.push(node); + } + } + } + out +} + +/// 递归装配 `id` 的子树:从 `by_id` 取出,按 `children_of` 把子节点装进 +/// `children`(保留任何已有的内联 children)。已取走的 id 返回 `None`(防环)。 +fn assemble( + id: &str, + by_id: &mut std::collections::HashMap, + children_of: &std::collections::HashMap>, +) -> Option { + let mut node = by_id.remove(id)?; + if let Some(child_ids) = children_of.get(id) { + let mut children: Vec = Vec::new(); + if let Some(existing) = node + .get_mut("children") + .and_then(serde_json::Value::as_array_mut) + { + children.append(existing); + } + for cid in child_ids { + if let Some(child) = assemble(cid, by_id, children_of) { + children.push(child); + } + } + if let Some(obj) = node.as_object_mut() { + obj.insert("children".to_string(), serde_json::Value::Array(children)); + } + } + Some(node) +} + +/// 逐根反序列化为 `Vec`(逐根容错:坏根跳过并记录;全有效时与直接 +/// 反序列化一致)。空 / 全坏返回 `Err` 以便上层试下一个候选。 +fn deserialize_roots(roots: Vec) -> Result, ParseError> { + let total = roots.len(); let mut nodes = Vec::with_capacity(total); let mut last_err: Option = None; - for item in items { - match serde_json::from_value::(item) { + for mut value in roots { + normalize_generated_node_json(&mut value); + match serde_json::from_value::(value) { Ok(node) => nodes.push(node), Err(e) => last_err = Some(e.to_string()), } @@ -112,12 +288,12 @@ fn try_parse_candidate(json: &str) -> Result, ParseError> { if nodes.is_empty() { let detail = last_err.map(|e| format!("; last: {e}")).unwrap_or_default(); return Err(ParseError(format!( - "deserialize: 0/{total} nodes valid{detail}" + "deserialize: 0/{total} roots valid{detail}" ))); } if nodes.len() < total { eprintln!( - "[parse] tolerated {} malformed node(s) of {total}, kept {}", + "[parse] tolerated {} malformed root(s) of {total}, kept {}", total - nodes.len(), nodes.len() ); @@ -142,14 +318,87 @@ fn normalize_generated_node_json(value: &mut serde_json::Value) { object.insert("src".into(), serde_json::Value::String(String::new())); } + // `fill` given as a bare color string/ref → wrap into the canonical + // `[{ "type":"solid", "color": }]` sequence. PenNode's fill + // is a sequence; models commonly shorthand it as a plain color + // (实测方舟 `"fill":"$color-success-text"` 否则整 root 报废)。 + if let Some(serde_json::Value::String(color)) = object.get("fill") { + let color = color.clone(); + object.insert( + "fill".into(), + serde_json::json!([{ "type": "solid", "color": color }]), + ); + } + for child in object.values_mut() { normalize_generated_node_json(child); } } + serde_json::Value::String(s) => { + // 数值型设计 token(`$type-*-size` 等)就地解析成数字 —— canonical + // PenNode 的 fontSize/lineHeight/gap/… 是 f64,模型却按 prompt 用这些 + // `$ref` 字符串,否则反序列化失败、整个 root 报废(实测方舟 metrics)。 + if let Some(n) = resolve_numeric_design_token(s) { + *value = serde_json::json!(n); + } + } _ => {} } } +/// 把**数值型**设计 token 字符串解析成 prompt 文档化的默认数值: +/// `$type-{tier}-{size|line-height|letter-spacing}`、`$spacing-{1..5}`、 +/// `$radius-{sm|md|lg}`。颜色 / weight 等非纯数值 token 返回 `None`(保持字符串 +/// —— 颜色 `$color-*` 在 fill 里合法,weight 是字符串型按字符串反序列化)。 +fn resolve_numeric_design_token(s: &str) -> Option { + if let Some(rest) = s.strip_prefix("$type-") { + if let Some(t) = rest.strip_suffix("-letter-spacing") { + return match t { + "display" => Some(-0.5), + "uppercase-label" => Some(1.5), + _ => None, + }; + } + let (tier, prop) = if let Some(t) = rest.strip_suffix("-line-height") { + (t, "lh") + } else if let Some(t) = rest.strip_suffix("-size") { + (t, "size") + } else { + return None; + }; + // (size, line-height) per the prompt's typography scale. + let (size, lh) = match tier { + "display" => (64.0, 1.0), + "h1" => (24.0, 1.2), + "h2" => (20.0, 1.25), + "h3" => (16.0, 1.3), + "body" => (14.0, 1.5), + "caption" => (12.0, 1.4), + _ => return None, + }; + return Some(if prop == "size" { size } else { lh }); + } + if let Some(n) = s.strip_prefix("$spacing-") { + return match n { + "1" => Some(4.0), + "2" => Some(8.0), + "3" => Some(12.0), + "4" => Some(16.0), + "5" => Some(24.0), + _ => None, + }; + } + if let Some(r) = s.strip_prefix("$radius-") { + return match r { + "sm" => Some(4.0), + "md" => Some(8.0), + "lg" => Some(12.0), + _ => None, + }; + } + None +} + /// 收集文本里可作为候选的 `open..close` 子串(`[...]` 或 `{...}`),按出现 /// 顺序返回。字符串字面量内的括号忽略。两条规则保证对脏输入鲁棒: /// - **独立尝试**每个 `open`:`balanced_end` 未闭合即跳过该位置(+1 前进)。 @@ -230,143 +479,5 @@ fn balanced_end(bytes: &[u8], start: usize, open: u8, close: u8) -> Option0)不被数组路径当节点数组,返回顶层 - // root 而不是内层 [a,b](Codex review)。 - let text = r#"{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [ - { "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, - { "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] } - ] }"#; - let nodes = parse_nodes(text).expect("parse"); - assert_eq!(nodes.len(), 1); - assert_eq!(nodes[0].id_str(), "root"); - } - - #[test] - fn parse_nodes_finds_array_after_unmatched_prose_bracket() { - // 散文里有个不闭合的 `[`(如 "options [1, 2 …"),其后才是真数组。 - // 独立尝试每个 `[` + 不闭合即跳过,后面的真数组不被漏掉(Codex - // review;修上一轮 combined-depth 引入的回归)。 - let text = "Consider options [a and b, then build:\n```json\n[{ \"type\": \"frame\", \"id\": \"hero\", \"name\": \"Hero\", \"x\": 0, \"y\": 0, \"width\": 200, \"height\": 100, \"children\": [] }]\n```"; - let nodes = - parse_nodes(text).expect("should find the real array after an unmatched prose bracket"); - assert_eq!(nodes.len(), 1); - assert_eq!(nodes[0].id_str(), "hero"); - } - - #[test] - fn parse_nodes_reads_labelled_wrapper_object() { - // 模型把节点数组包进带标签的对象 {"nodes":[...]}。colon-skip 曾把 - // "nodes":[...] 一并跳掉;现在按总节点数择优——wrapper 无 type 解析 - // 失败,数组路径取出 [a,b](Codex review)。 - let text = r#"```json -{ "nodes": [ - { "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, - { "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] } -] } -```"#; - let nodes = parse_nodes(text).expect("labelled wrapper array should be parsed"); - assert_eq!(nodes.len(), 2); - assert_eq!(nodes[0].id_str(), "a"); - assert_eq!(nodes[1].id_str(), "b"); - } -} +#[path = "parse_tests.rs"] +mod tests; diff --git a/crates/op-orchestrator/src/parse_tests.rs b/crates/op-orchestrator/src/parse_tests.rs new file mode 100644 index 000000000..79addf699 --- /dev/null +++ b/crates/op-orchestrator/src/parse_tests.rs @@ -0,0 +1,330 @@ +use super::*; +use op_editor_core::PenNodeExt as _; + +#[test] +fn parse_nodes_reads_fenced_pennode_array() { + // 一个最小的 frame 节点(canonical schema:`type` 标签 + + // base 字段)。 + let text = r#"Sure, here are the nodes: +```json +[ + { "type": "frame", "id": "hero", "name": "Hero", "x": 0, "y": 0, "width": 1200, "height": 400, "children": [] } +] +```"#; + let nodes = parse_nodes(text).expect("parse"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "hero"); +} + +#[test] +fn parse_nodes_rejects_empty_array() { + assert!(parse_nodes("[]").is_err()); +} + +#[test] +fn parse_nodes_rejects_no_array() { + assert!(parse_nodes("the model wrote prose only").is_err()); +} + +#[test] +fn parse_nodes_defaults_missing_image_src_to_empty_string() { + let text = r#"[ + { "type": "image", "id": "photo", "name": "Restaurant photo", "x": 0, "y": 0, "width": 240, "height": 160 } +]"#; + + let nodes = parse_nodes(text).expect("missing image src should be tolerated"); + let PenNode::Image(image) = &nodes[0] else { + panic!("expected image node"); + }; + assert_eq!(image.src, ""); +} + +#[test] +fn parse_nodes_skips_prose_bracket_before_fenced_array() { + // 弱模型在真正 JSON 之前写了带 `[` 的推理散文。老逻辑取第一个 + // `[` 会抓到 `[step 1]` 报 "expected ident";新逻辑扫描所有平衡 + // 数组、取节点数最多的有效结果,跳过散文括号。 + let text = "Let me plan this [step 1]: build the card.\n```json\n[\n { \"type\": \"frame\", \"id\": \"card\", \"name\": \"Card\", \"x\": 0, \"y\": 0, \"width\": 300, \"height\": 200, \"children\": [] }\n]\n```"; + let nodes = parse_nodes(text).expect("should skip prose bracket and parse real array"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "card"); +} + +#[test] +fn parse_nodes_tolerates_one_bad_node_keeps_rest() { + // 弱模型在节点数组里混入一个 `type:"solid"`(fill 类型当节点)。 + // 老的整组 from_value 会因这一个坏元素丢掉整段;新逻辑逐元素 + // 反序列化、跳过坏的、保留有效节点(实测 Dashboard charts-row)。 + let text = r##"[ + { "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, + { "type": "solid", "color": "#06B6D4" }, + { "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] } + ]"##; + let nodes = parse_nodes(text).expect("should keep the valid nodes, skip the bad one"); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].id_str(), "a"); + assert_eq!(nodes[1].id_str(), "b"); +} + +#[test] +fn parse_nodes_returns_outer_array_not_nested_children() { + // 嵌套格式:外层只有 1 个 root frame,其 children 有 3 个。只收 + // 顶层数组,返回外层 [root],而不是内层 [a,b,c](Codex review)。 + let text = r#"[ + { "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [ + { "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, + { "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, + { "type": "frame", "id": "c", "name": "C", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] } + ] } + ]"#; + let nodes = parse_nodes(text).expect("parse"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "root"); +} + +#[test] +fn parse_nodes_reads_jsonl_bare_objects() { + // DeepSeek 输出 JSONL:```json 围栏里每行一个裸对象,无 [ ] 包裹。 + // 数组路径只看到内层 children:[] 空数组(失败),JSONL 回退按顶层 + // {...} 逐个解析。 + let text = "```json\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n```"; + let nodes = parse_nodes(text).expect("JSONL fallback should parse bare objects"); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].id_str(), "a"); + assert_eq!(nodes[1].id_str(), "b"); +} + +#[test] +fn parse_nodes_bare_object_returns_top_level_not_nested_children() { + // 单个裸对象(无数组包裹)带真实嵌套 children。合并深度判定下, + // children:[...] 在 {} 内(深度>0)不被数组路径当节点数组,返回顶层 + // root 而不是内层 [a,b](Codex review)。 + let text = r#"{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [ + { "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, + { "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] } + ] }"#; + let nodes = parse_nodes(text).expect("parse"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "root"); +} + +#[test] +fn parse_nodes_finds_array_after_unmatched_prose_bracket() { + // 散文里有个不闭合的 `[`(如 "options [1, 2 …"),其后才是真数组。 + // 独立尝试每个 `[` + 不闭合即跳过,后面的真数组不被漏掉(Codex + // review;修上一轮 combined-depth 引入的回归)。 + let text = "Consider options [a and b, then build:\n```json\n[{ \"type\": \"frame\", \"id\": \"hero\", \"name\": \"Hero\", \"x\": 0, \"y\": 0, \"width\": 200, \"height\": 100, \"children\": [] }]\n```"; + let nodes = + parse_nodes(text).expect("should find the real array after an unmatched prose bracket"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "hero"); +} + +#[test] +fn parse_nodes_reads_labelled_wrapper_object() { + // 模型把节点数组包进带标签的对象 {"nodes":[...]}。colon-skip 曾把 + // "nodes":[...] 一并跳掉;现在按总节点数择优——wrapper 无 type 解析 + // 失败,数组路径取出 [a,b](Codex review)。 + let text = r#"```json +{ "nodes": [ + { "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }, + { "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] } +] } +```"#; + let nodes = parse_nodes(text).expect("labelled wrapper array should be parsed"); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].id_str(), "a"); + assert_eq!(nodes[1].id_str(), "b"); +} + +#[test] +fn parse_nodes_reads_ark_metrics_jsonl_regression() { + // 实采:方舟 ark-code-latest 的 metrics 子任务 —— 31 行合法 JSONL、1 个 + // root(`_parent:null`)、所有 `_parent` 可解析。实测却 `0/0 roots valid` + // 失败(benchmark 矩阵复现)。固化为回归 fixture。 + let text = include_str!("test_fixtures/ark_metrics.jsonl"); + let nodes = parse_nodes(text).expect("valid 31-node _parent JSONL must parse"); + assert_eq!(nodes.len(), 1, "single metrics-root after re-nest"); + assert_eq!(nodes[0].id_str(), "metrics-root"); + // 31 行里有 1 个重复 id(方舟自身),去重后 30 个 unique 节点。 + assert!( + total_nodes(&nodes) >= 29, + "expected ~30 nodes, got {}", + total_nodes(&nodes) + ); +} + +#[test] +fn resolve_numeric_design_token_table() { + assert_eq!( + resolve_numeric_design_token("$type-caption-size"), + Some(12.0) + ); + assert_eq!( + resolve_numeric_design_token("$type-display-size"), + Some(64.0) + ); + assert_eq!( + resolve_numeric_design_token("$type-body-line-height"), + Some(1.5) + ); + assert_eq!( + resolve_numeric_design_token("$type-display-letter-spacing"), + Some(-0.5) + ); + assert_eq!(resolve_numeric_design_token("$spacing-3"), Some(12.0)); + assert_eq!(resolve_numeric_design_token("$radius-md"), Some(8.0)); + // colors / weight / sizing keywords stay strings (not numeric tokens). + assert_eq!(resolve_numeric_design_token("$color-accent"), None); + assert_eq!(resolve_numeric_design_token("$type-body-weight"), None); + assert_eq!(resolve_numeric_design_token("fill_container"), None); +} + +#[test] +fn normalize_resolves_numeric_token_and_wraps_bare_fill() { + let mut v = serde_json::json!({ + "type":"text","id":"x","fontSize":"$type-caption-size","fill":"$color-surface" + }); + normalize_generated_node_json(&mut v); + assert_eq!(v["fontSize"], serde_json::json!(12.0)); + assert_eq!( + v["fill"], + serde_json::json!([{"type":"solid","color":"$color-surface"}]) + ); +} + +#[test] +fn parse_nodes_renests_flat_parent_array() { + // 扁平 `_parent` 数组(M2.7 形态):root + 一个横向 row + row 的 3 个 + // 子项,全是兄弟、靠 `_parent` 指父。移植 TS parseJsonlToTree 后应重组 + // 成树:root 直接子 = [row],row 子 = [a,b,c],而非 5 个扁平兄弟 + // (扁平 → 横向容器子项跑到 root 下 → 布局竖排破损,这正是回归点)。 + let text = r#"[ + { "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "layout": "vertical", "_parent": null }, + { "type": "frame", "id": "row", "name": "Row", "width": "fill_container", "height": "fit_content", "layout": "horizontal", "_parent": "root" }, + { "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "row" }, + { "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": "row" }, + { "type": "frame", "id": "c", "name": "C", "width": 80, "height": 50, "_parent": "row" } + ]"#; + let nodes = parse_nodes(text).expect("flat _parent array should re-nest into a tree"); + assert_eq!(nodes.len(), 1, "single root after re-nest"); + assert_eq!(nodes[0].id_str(), "root"); + assert_eq!( + crate::cleanup::count_descendants(&nodes[0]), + 4, + "root subtree = row + a + b + c" + ); +} + +#[test] +fn parse_nodes_renests_flat_parent_jsonl() { + // DeepSeek 形态:JSONL 裸对象,每个带 `_parent`。重组后 root 含嵌套子树 + // (2 个直接子),而非 3 个扁平兄弟。 + let text = "```json\n{\"type\":\"frame\",\"id\":\"root\",\"name\":\"Root\",\"x\":0,\"y\":0,\"width\":300,\"height\":200,\"_parent\":null}\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"width\":80,\"height\":50,\"_parent\":\"root\"}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"width\":80,\"height\":50,\"_parent\":\"root\"}\n```"; + let nodes = parse_nodes(text).expect("flat _parent JSONL should re-nest"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "root"); + assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 2); +} + +#[test] +fn parse_nodes_renest_orphan_parent_becomes_root() { + // `_parent` 指向不存在的 id → 该节点当 root(TS parseJsonlToTree: + // "Parent not found, treat as root")。两个节点都成 root。 + let text = r#"[ + { "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "ghost" }, + { "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": null } + ]"#; + let nodes = parse_nodes(text).expect("orphan parent should fall back to root"); + assert_eq!(nodes.len(), 2); +} + +#[test] +fn parse_nodes_renest_cycle_is_lossless() { + // 病态输入:纯 `_parent` 环(a↔b)。两者互为父 → 没有天然 root。 + // 无丢失保证应把它们装配出来(环被 take 打断),不静默丢节点。 + let text = r#"[ + { "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "b" }, + { "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": "a" } + ]"#; + let nodes = parse_nodes(text).expect("a _parent cycle must not drop all nodes"); + // a 先序、被提升为 root,b 作为 a 的子;总节点数 2(无丢失)。 + assert_eq!(total_nodes(&nodes), 2); +} + +#[test] +fn parse_nodes_ignores_draft_inside_think_block() { + // 推理模型(MiniMax-M3)在 里写**未完成的扁平草稿**,`` + // 之后才是真答案。应解析真答案(嵌套 root),而不是 think 里的扁平草稿 + // (否则得到 M3 实测的扁平 depth-2 残骸)。 + let text = r#" + Let me draft the cards: [ + {"type":"frame","id":"draft-a","name":"A","width":80,"height":50}, + {"type":"frame","id":"draft-b","name":"B","width":80,"height":50} + ] + and similarly for the rest... + + [ + {"type":"frame","id":"root","name":"Root","x":0,"y":0,"width":300,"height":200,"layout":"horizontal","_parent":null}, + {"type":"frame","id":"a","name":"A","width":80,"height":50,"_parent":"root"}, + {"type":"frame","id":"b","name":"B","width":80,"height":50,"_parent":"root"} + ]"#; + let nodes = parse_nodes(text).expect("should parse the real answer after "); + assert_eq!( + nodes.len(), + 1, + "real answer is 1 nested root, not the 2-node draft" + ); + assert_eq!(nodes[0].id_str(), "root"); + assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 2); +} + +#[test] +fn strip_reasoning_takes_text_after_last_close_tag() { + assert_eq!(strip_reasoning("draftREAL").trim(), "REAL"); + assert_eq!(strip_reasoning("no tags here"), "no tags here"); + // 在 think 中被截断(无闭合标签)→ 返回开标签之前(此处空), + // 不把未完成草稿当输出。 + assert_eq!(strip_reasoning("cut off draft"), ""); + assert_eq!(strip_reasoning("preamble cut off"), "preamble "); + // 闭合块 + 内容 + 末尾被截断的第二个 think 块 → 只保留中间内容 + // (Codex review:截断的第二个 think 块仍被当真节点)。 + assert_eq!( + strip_reasoning("planREALredo [{\"id\"").trim(), + "REAL" + ); +} + +#[test] +fn parse_nodes_rejects_truncated_think_draft() { + // 打开但被 max_tokens 截断(无 ),里面是未完成的草稿 + // JSON。绝不能把草稿当真节点解析(Codex review:truncated think 仍被 + // 当真节点)——strip 到 之前(空)→ 解析失败、上层重试。 + let text = r#" + Let me draft the cards: [ + {"type":"frame","id":"draft","name":"D","width":80,"height":50} + "#; + assert!( + parse_nodes(text).is_err(), + "truncated think draft must not parse as real nodes" + ); +} + +#[test] +fn parse_nodes_ignores_draft_in_truncated_second_think() { + // 闭合 think#1 + 真答案 + 被截断的 think#2(含草稿)。只解析真答案, + // 不碰 think#2 草稿(Codex review:截断的第二个 think 块仍被当真节点)。 + let text = r#"planning the layout + [ + {"type":"frame","id":"root","name":"Root","x":0,"y":0,"width":300,"height":200,"layout":"horizontal","_parent":null}, + {"type":"frame","id":"a","name":"A","width":80,"height":50,"_parent":"root"} + ] + wait, let me reconsider and draft more cards: [ + {"type":"frame","id":"draft-x","name":"X","width":99,"height":99} + "#; + let nodes = parse_nodes(text).expect("real answer between think blocks must parse"); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].id_str(), "root"); + // 只有 a;draft-x(在被截断的 think#2 里)不计入。 + assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 1); +} diff --git a/crates/op-orchestrator/src/test_fixtures/ark_metrics.jsonl b/crates/op-orchestrator/src/test_fixtures/ark_metrics.jsonl new file mode 100644 index 000000000..a7c7e1133 --- /dev/null +++ b/crates/op-orchestrator/src/test_fixtures/ark_metrics.jsonl @@ -0,0 +1,31 @@ +{"_parent":null,"id":"metrics-root","type":"frame","name":"Metric Cards Row","role":"section","width":"fill_container","height":"fit_content","layout":"vertical","gap":0,"padding":[12,0]} +{"_parent":"metrics-root","id":"metrics-row","type":"frame","name":"Metrics Cards","role":"row","width":"fill_container","height":156,"layout":"horizontal","gap":20,"padding":0} +{"_parent":"metrics-row","id":"metrics-card-revenue","type":"frame","name":"Revenue Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]} +{"_parent":"metrics-card-revenue","id":"metrics-revenue-top","type":"frame","name":"Revenue Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0} +{"_parent":"metrics-revenue-top","id":"metrics-revenue-label","type":"text","name":"Revenue Label","role":"label","content":"Revenue","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]} +{"_parent":"metrics-revenue-top","id":"metrics-revenue-badge","type":"frame","name":"Revenue Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-success-bg"}]} +{"_parent":"metrics-revenue-badge","id":"metrics-revenue-trend-icon","type":"icon_font","name":"Revenue Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-success-text"} +{"_parent":"metrics-revenue-badge","id":"metrics-revenue-change","type":"text","name":"Revenue Change","content":"+12.5%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-success-text"}]} +{"_parent":"metrics-card-revenue","id":"metrics-revenue-value","type":"text","name":"Revenue Value","content":"$128.4K","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]} +{"_parent":"metrics-card-users","id":"metrics-users-top","type":"frame","name":"Users Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0} +{"_parent":"metrics-row","id":"metrics-card-users","type":"frame","name":"Active Users Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]} +{"_parent":"metrics-card-users","id":"metrics-users-top","type":"frame","name":"Users Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0} +{"_parent":"metrics-users-top","id":"metrics-users-label","type":"text","name":"Users Label","role":"label","content":"Active Users","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]} +{"_parent":"metrics-users-top","id":"metrics-users-badge","type":"frame","name":"Users Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-success-bg"}]} +{"_parent":"metrics-users-badge","id":"metrics-users-trend-icon","type":"icon_font","name":"Users Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-success-text"} +{"_parent":"metrics-users-badge","id":"metrics-users-change","type":"text","name":"Users Change","content":"+8.2%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-success-text"}]} +{"_parent":"metrics-card-users","id":"metrics-users-value","type":"text","name":"Users Value","content":"24,892","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]} +{"_parent":"metrics-row","id":"metrics-card-profit","type":"frame","name":"Profit Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]} +{"_parent":"metrics-card-profit","id":"metrics-profit-top","type":"frame","name":"Profit Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0} +{"_parent":"metrics-profit-top","id":"metrics-profit-label","type":"text","name":"Profit Label","role":"label","content":"Net Profit","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]} +{"_parent":"metrics-profit-top","id":"metrics-profit-badge","type":"frame","name":"Profit Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-danger-bg"}]} +{"_parent":"metrics-profit-badge","id":"metrics-profit-trend-icon","type":"icon_font","name":"Profit Trend Icon","iconFontName":"trending-down","width":14,"height":14,"fill":"$color-danger-text"} +{"_parent":"metrics-profit-badge","id":"metrics-profit-change","type":"text","name":"Profit Change","content":"-3.1%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-danger-text"}]} +{"_parent":"metrics-card-profit","id":"metrics-profit-value","type":"text","name":"Profit Value","content":"$38.9K","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]} +{"_parent":"metrics-row","id":"metrics-card-conversion","type":"frame","name":"Conversion Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]} +{"_parent":"metrics-card-conversion","id":"metrics-conversion-top","type":"frame","name":"Conversion Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0} +{"_parent":"metrics-conversion-top","id":"metrics-conversion-label","type":"text","name":"Conversion Label","role":"label","content":"Conversion","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]} +{"_parent":"metrics-conversion-top","id":"metrics-conversion-badge","type":"frame","name":"Conversion Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-info-bg"}]} +{"_parent":"metrics-conversion-badge","id":"metrics-conversion-trend-icon","type":"icon_font","name":"Conversion Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-info-text"} +{"_parent":"metrics-conversion-badge","id":"metrics-conversion-change","type":"text","name":"Conversion Change","content":"+1.8%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-info-text"}]} +{"_parent":"metrics-card-conversion","id":"metrics-conversion-value","type":"text","name":"Conversion Value","content":"6.84%","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]} \ No newline at end of file