OX
OPEN-OX

// docs / normalize

Blueprint 容错解析

LLM 输出 JSON 时经常出现字段缺失、类型错误、结构不一致。asProjectBlueprint() 函数支持三种输出格式, 并对每个字段做防御性 normalize — 比在 prompt 里反复强调"必须输出完整 JSON"更可靠。

问题背景

analyze_project_requirement 步骤要求 LLM 输出一个嵌套很深的 JSON 结构。 实际观察到的失败模式:

字段缺失roles 数组为空,或 productScope 整个缺失
类型错误goals 应为 string[] 但输出了单个 string
结构变体把所有字段平铺在顶层,而非嵌套在 brief/experience/site 下
单页简化只输出 title + sections,没有多页结构
JSON 截断上下文窗口不足时输出不完整的 JSON
在 prompt 中反复强调"必须输出完整 JSON"会增加 token 消耗,但并不能完全消除这些问题。 防御性解析比 prompt 工程更可靠。
与结构化 Blueprint 并行执行的 infer_design_intent 产出自由文本设计意图; 流水线在规划完成后会把其中的 technicalKeywords 并入 experience.designIntent.keywords,以便下游 skill 路由获取 analyze JSON 未覆盖的信号。

三种输出格式

asProjectBlueprint() 按优先级依次尝试三种解析路径:

function asProjectBlueprint(value: unknown): ProjectBlueprint {
  // 路径 1:标准嵌套结构(最常见)
  if (candidate.brief && candidate.experience && candidate.site) {
    return {
      brief: normalizeBrief(candidate.brief),
      experience: normalizeExperience(candidate.experience),
      site: normalizeSite(candidate.site),
    };
  }

  // 路径 2:扁平结构(LLM 有时会平铺所有字段)
  if (
    typeof flatCandidate.projectTitle === "string" &&
    flatCandidate.designIntent &&
    Array.isArray(flatCandidate.pages)
  ) {
    return { brief: ..., experience: ..., site: ... }; // 重新组装
  }

  // 路径 3:单页简化结构(用户只描述了一个页面)
  if (
    typeof singlePage.title === "string" &&
    singlePage.designIntent &&
    isSectionSpecArray(singlePage.sections)
  ) {
    return { brief: ..., experience: ..., site: ... };
  }

  throw new Error("output does not match ProjectBlueprint");
}

字段级容错

每个字段都有独立的 normalize 函数,处理类型错误和缺失值:

roles 容错

function normalizeRoles(value: unknown): UserRole[] {
  // 空数组或非数组 → 自动创建默认 Visitor 角色
  if (!Array.isArray(value) || value.length === 0) {
    return [{
      roleId: "visitor",
      roleName: "Visitor",
      goals: ["Understand the offer", "Complete the primary conversion path"],
      coreActions: ["Scan the main message", "Take the primary CTA"],
      priority: "primary",
    }];
  }
  // 每个角色字段单独 normalize,缺失字段用合理默认值填充
  return value.map((item, index) => ({
    roleId: typeof item.roleId === "string" ? item.roleId
          : item.roleName?.toLowerCase().replace(/[^a-z0-9]+/g, "-")
          ?? `role-${index + 1}`,
    roleName: typeof item.roleName === "string" ? item.roleName : `Role ${index + 1}`,
    goals: isStringArray(item.goals) ? item.goals : ["Support the role's core goal"],
    // ...
  }));
}

fileName 的 PascalCase 转换

Section 的 fileName 字段会被转为 PascalCase, 确保生成的 TSX 文件名符合 React 组件命名规范:

function toPascalCase(value: string): string {
  return value
    .split(/[^a-zA-Z0-9]+/)  // 按非字母数字分割
    .filter(Boolean)
    .map(part => part.charAt(0).toUpperCase() + part.slice(1))
    .join("");
}

// 示例
toPascalCase("hero-section")    → "HeroSection"
toPascalCase("pricing_table")   → "PricingTable"
toPascalCase("FAQ section")     → "FAQSection"

language 字段

language 字段决定所有生成内容的语言(中文/英文等)。 如果 LLM 没有输出或输出空字符串,默认为 "en"

language: typeof candidate.language === "string" && candidate.language.trim()
  ? candidate.language.trim()
  : "en",

Fallback 策略

各层级的 fallback 设计原则:宁可生成一个合理的默认值,也不要抛出错误。

字段缺失时的 Fallback
roles自动创建 Visitor 角色(primary 优先级)
taskLoops基于第一个 role 创建默认核心旅程
capabilities空数组(不强制要求)
productScope从 projectDescription 推导
informationArchitecture从 pages 数组自动构建
language"en"
fileName从 type 字段 PascalCase 转换
priority (role)"primary"
priority (capability)"must-have"
只有两个字段是硬性必须的:brief.projectTitlebrief.projectDescription。缺失这两个字段会抛出错误, 因为没有合理的 fallback 可以替代用户的核心意图。