Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-06-24-llm-schema-compatibility.md

29 KiB
Raw Permalink Blame History

LLM Schema Compatibility Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make near-valid LLM JSON output resilient enough that object-shaped list items, Chinese QA labels, and harmless optional fields no longer block fact-card, optimized-article, or QA display.

Architecture: Keep the hard contract at the shared Zod boundary in src/lib/domain/validation.ts, because fact extraction, article optimization, targeted rewrite, and QA all pass through that module. Normalize common LLM shape drift before strict parsing, while still rejecting missing required article text and unknown critical enum values. Strengthen prompt builders with explicit output type contracts so the model has fewer chances to drift.

Tech Stack: Next.js 16 API routes, TypeScript, Zod 4, Vitest, existing generateValidatedJson workflow.


File Structure

  • Modify src/lib/domain/validation.ts: add shared LLM normalization helpers, apply them to fact cards, optimized articles, image suggestions, and QA schemas.
  • Modify src/lib/domain/__tests__/validation.test.ts: add regression tests for object-shaped fact-card lists, optimized article optional arrays, and QA Chinese/alias values.
  • Modify src/lib/llm/prompts.ts: add explicit JSON output contracts and examples for fact extraction, article optimization, QA inspection, and targeted rewrite.
  • Modify src/lib/llm/__tests__/prompts.test.ts: verify the prompts name the strict array and enum output requirements.

Current Failure Model

  • /api/jobs calls extractCandidateFactCard(), which validates the raw LLM response against candidateFactCardSchema. If any fact-card field fails, the route returns a 502 and the frontend never receives candidateFactCard.
  • /api/jobs/[jobId]/optimize runs optimizeArticle(), inspectQualityWithLlm(), and sometimes rewriteFailedSections() in sequence. A schema failure in any step aborts the route and the frontend does not receive optimizedArticle or qaReport.
  • Existing compatibility covers target_audience, experience_years, and changed_sections. It does not cover most string-array fields, requires_user_confirmation, malformed image_suggestions, Chinese QA statuses, Chinese QA rule names, or empty target_agent.

Task 1: Normalize Fact-Card LLM Fields

Files:

  • Modify: src/lib/domain/validation.ts

  • Test: src/lib/domain/__tests__/validation.test.ts

  • Step 1: Write the failing fact-card compatibility test

Append this test inside the existing describe("domain validation", () => { ... }) block in src/lib/domain/__tests__/validation.test.ts, directly after normalizes near-valid LLM fact card field types.

  it("normalizes object-shaped and single-string LLM fact card fields", () => {
    const parsed = candidateFactCardSchema.parse({
      company_full_name: { name: "示例科技有限公司" },
      company_short_names: "示例科技",
      brand_names: [{ name: "示例品牌" }],
      product_names: [{ product: "GEO内容优化平台" }],
      target_industry: { industry: "GEO内容优化" },
      target_audience: { audience: "市场团队" },
      experience_years: { years: "8年" },
      core_claims: [
        { claim: "提供GEO内容优化服务", source: "原文明确出现" },
      ],
      forbidden_claims: [
        { claim: "行业第一", reason: "缺少第三方依据" },
      ],
      image_topics: [{ topic: "产品后台截图" }],
      uncertain_items: [
        { item: "客户案例", reason: "原文没有给出客户名称" },
        { claim: "出海能力", evidence: "只出现营销表述" },
      ],
    });

    expect(parsed.company_full_name).toBe("示例科技有限公司");
    expect(parsed.company_short_names).toEqual(["示例科技"]);
    expect(parsed.brand_names).toEqual(["示例品牌"]);
    expect(parsed.product_names).toEqual(["GEO内容优化平台"]);
    expect(parsed.target_industry).toBe("GEO内容优化");
    expect(parsed.target_audience).toBe("市场团队");
    expect(parsed.experience_years).toBe(8);
    expect(parsed.core_claims).toEqual(["提供GEO内容优化服务"]);
    expect(parsed.forbidden_claims).toEqual(["行业第一"]);
    expect(parsed.image_topics).toEqual(["产品后台截图"]);
    expect(parsed.uncertain_items).toEqual(["客户案例", "出海能力"]);
    expect(parsed.is_ready_for_optimization).toBe(false);
  });
  • Step 2: Run the failing fact-card test

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts -t "normalizes object-shaped and single-string LLM fact card fields"

Expected: FAIL with a Zod error mentioning company_full_name, company_short_names, or uncertain_items.

  • Step 3: Add shared string normalization helpers

In src/lib/domain/validation.ts, replace the existing stringOrStringArraySchema helper and move firstStringField above the fact-card schemas so all schema sections can reuse it. The helper area after articleInputSchema should look like this:

function isPlainRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function firstStringField(record: Record<string, unknown>, keys: string[]) {
  for (const key of keys) {
    const value = record[key];
    if (typeof value === "string" && value.trim().length > 0) {
      return value.trim();
    }
    if (typeof value === "number" && Number.isFinite(value)) {
      return String(value);
    }
  }
  return null;
}

const llmStringKeys = [
  "value",
  "text",
  "name",
  "title",
  "summary",
  "claim",
  "item",
  "topic",
  "audience",
  "industry",
  "company",
  "company_name",
  "brand",
  "product",
  "product_name",
  "content",
  "body",
  "markdown",
  "body_markdown",
  "reason",
  "description",
  "evidence",
  "source",
  "suggestion",
  "fix",
  "change",
  "changed",
  "after",
];

function normalizedStringOrNull(value: unknown) {
  const normalized = normalizeStringValue(value);
  return typeof normalized === "string" && normalized.trim().length > 0
    ? normalized.trim()
    : null;
}

function normalizeStringValue(value: unknown): unknown {
  if (typeof value === "string") return value.trim();
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
  if (Array.isArray(value)) {
    return value
      .map(normalizedStringOrNull)
      .filter((item): item is string => Boolean(item))
      .join("、");
  }
  if (!isPlainRecord(value)) return value;

  const direct = firstStringField(value, llmStringKeys);
  if (direct) return direct;

  const stringValues = Object.values(value)
    .map(normalizedStringOrNull)
    .filter((item): item is string => Boolean(item));
  return stringValues.join("");
}

function normalizeStringList(value: unknown): unknown {
  if (value == null) return [];
  const items = Array.isArray(value) ? value : [value];
  return items
    .map(normalizedStringOrNull)
    .filter((item): item is string => Boolean(item));
}

const llmStringSchema = z.preprocess(normalizeStringValue, z.string().trim());

const requiredLlmStringSchema = z.preprocess(
  normalizeStringValue,
  z.string().trim().min(1),
);

const optionalLlmStringSchema = z.preprocess((value) => {
  if (value == null) return "";
  return normalizeStringValue(value);
}, z.string().trim().default(""));

const stringListSchema = z.preprocess(
  normalizeStringList,
  z.array(z.string().trim().min(1)).default([]),
);
  • Step 4: Normalize experience years from object-shaped values

Replace the current experienceYearsSchema block in src/lib/domain/validation.ts with:

function normalizeExperienceYears(value: unknown): unknown {
  let candidate = value;
  if (Array.isArray(candidate)) {
    candidate = candidate[0] ?? null;
  }
  if (isPlainRecord(candidate)) {
    for (const key of ["years", "year", "experience_years", "value"]) {
      const entry = candidate[key];
      if (typeof entry === "number" && Number.isFinite(entry)) return entry;
    }
    candidate =
      firstStringField(candidate, [
        "years",
        "year",
        "experience_years",
        "value",
        "text",
        "description",
      ]) ?? candidate;
  }
  if (typeof candidate !== "string") return candidate;

  const trimmed = candidate.trim();
  if (
    trimmed === "" ||
    /^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test(
      trimmed,
    )
  ) {
    return null;
  }
  const yearMatch = trimmed.match(/\d{1,3}/);
  return yearMatch ? Number(yearMatch[0]) : candidate;
}

const experienceYearsSchema = z.preprocess(
  normalizeExperienceYears,
  z.number().int().nonnegative().nullable().default(null),
);
  • Step 5: Apply the normalized schemas to fact-card fields

Replace factCardBaseSchema in src/lib/domain/validation.ts with:

const factCardBaseSchema = z.object({
  company_full_name: llmStringSchema,
  company_short_names: stringListSchema,
  brand_names: stringListSchema,
  product_names: stringListSchema,
  target_industry: llmStringSchema,
  target_audience: llmStringSchema,
  experience_years: experienceYearsSchema,
  core_claims: stringListSchema,
  forbidden_claims: stringListSchema,
  image_topics: stringListSchema,
  uncertain_items: stringListSchema,
});

Remove the older duplicate firstStringField definition near imageSuggestionSchema; the shared version above now serves both fact-card and changed-section normalization.

  • Step 6: Run the fact-card compatibility test

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts -t "normalizes object-shaped and single-string LLM fact card fields"

Expected: PASS.

  • Step 7: Run all domain validation tests

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts

Expected: PASS for every test in domain validation.

  • Step 8: Commit Task 1

Run:

git add src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts
git commit -m "fix: 兼容事实卡LLM字段形状"

Task 2: Normalize Optimized-Article Optional Arrays

Files:

  • Modify: src/lib/domain/validation.ts

  • Test: src/lib/domain/__tests__/validation.test.ts

  • Step 1: Write the failing optimized-article compatibility test

Append this test inside describe("domain validation", () => { ... }) in src/lib/domain/__tests__/validation.test.ts, directly after normalizes object-shaped changed sections from LLM output.

  it("normalizes near-valid optimized article LLM optional fields", () => {
    const parsed = optimizedArticleSchema.parse({
      title: { text: "示例科技 GEO 内容优化方案" },
      summary: ["围绕事实卡重写官网文章摘要"],
      body_markdown: {
        markdown: "## 服务能力\n示例科技有限公司提供GEO内容优化服务。",
      },
      image_suggestions: [
        "当前版本不生成图片建议",
        { source: "image_1" },
        { suggestion: "使用产品后台截图" },
        { source: "image_2", suggestion: "保留原文截图说明" },
      ],
      changed_sections: [
        { section: "title", change: "改成中文官网标题" },
      ],
      requires_user_confirmation: [
        { claim: "客户案例", reason: "原文没有给出客户名称" },
      ],
    });

    expect(parsed.title).toBe("示例科技 GEO 内容优化方案");
    expect(parsed.summary).toBe("围绕事实卡重写官网文章摘要");
    expect(parsed.body_markdown).toContain("## 服务能力");
    expect(parsed.image_suggestions).toEqual([
      { source: "image_2", suggestion: "保留原文截图说明" },
    ]);
    expect(parsed.changed_sections).toEqual(["title: 改成中文官网标题"]);
    expect(parsed.requires_user_confirmation).toEqual(["客户案例"]);
  });
  • Step 2: Run the failing optimized-article test

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts -t "normalizes near-valid optimized article LLM optional fields"

Expected: FAIL with a Zod error mentioning title, summary, body_markdown, image_suggestions, or requires_user_confirmation.

  • Step 3: Add image-suggestion normalization

In src/lib/domain/validation.ts, directly after imageSuggestionSchema, add:

function normalizeImageSuggestions(value: unknown): unknown {
  if (!Array.isArray(value)) return [];

  return value.flatMap((item) => {
    if (!isPlainRecord(item)) return [];
    const source = normalizedStringOrNull(
      item.source ?? item.image ?? item.name ?? item.title,
    );
    const suggestion = normalizedStringOrNull(
      item.suggestion ?? item.description ?? item.reason ?? item.fix ?? item.text,
    );
    if (!source || !suggestion) return [];
    return [{ source, suggestion }];
  });
}

const imageSuggestionsSchema = z.preprocess(
  normalizeImageSuggestions,
  z.array(imageSuggestionSchema).default([]),
);
  • Step 4: Apply normalized schemas to optimized articles

Replace optimizedArticleSchema in src/lib/domain/validation.ts with:

export const optimizedArticleSchema = z.object({
  job_id: z.string().trim().min(1).optional(),
  revision: z.number().int().positive().optional(),
  title: requiredLlmStringSchema,
  summary: requiredLlmStringSchema,
  body_markdown: requiredLlmStringSchema,
  image_suggestions: imageSuggestionsSchema,
  changed_sections: z.array(changedSectionSchema).default([]),
  requires_user_confirmation: stringListSchema,
}) satisfies z.ZodType<OptimizedArticle>;
  • Step 5: Run the optimized-article compatibility test

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts -t "normalizes near-valid optimized article LLM optional fields"

Expected: PASS.

  • Step 6: Run all domain validation tests

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts

Expected: PASS for every test in domain validation.

  • Step 7: Commit Task 2

Run:

git add src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts
git commit -m "fix: 兼容优化稿LLM可选字段"

Task 3: Normalize QA Statuses, Rule IDs, and Text Fields

Files:

  • Modify: src/lib/domain/validation.ts

  • Test: src/lib/domain/__tests__/validation.test.ts

  • Step 1: Write the failing QA compatibility test

Append this test inside describe("domain validation", () => { ... }) in src/lib/domain/__tests__/validation.test.ts, directly after accepts QA reports only with pass, warn, or fail statuses.

  it("normalizes near-valid LLM QA report values", () => {
    const parsed = qaReportSchema.parse({
      overall_status: "警告",
      checks: [
        {
          rule_id: "标题质量",
          status: "警告",
          evidence: { detail: "标题仍然偏营销化" },
          reason: { reason: "官网标题需要更克制" },
          suggested_fix: null,
          target_agent: "",
        },
        {
          rule_id: "company_name_integrity",
          status: "通过",
          evidence: "公司全称一致",
          reason: "正文保留了事实卡中的公司全称",
          target_agent: "无",
        },
      ],
    });

    expect(parsed.overall_status).toBe("warn");
    expect(parsed.checks[0]).toEqual({
      rule_id: "title_quality",
      status: "warn",
      evidence: "标题仍然偏营销化",
      reason: "官网标题需要更克制",
      suggested_fix: "",
      target_agent: null,
    });
    expect(parsed.checks[1]?.target_agent).toBeNull();
  });
  • Step 2: Run the failing QA compatibility test

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts -t "normalizes near-valid LLM QA report values"

Expected: FAIL with a Zod error mentioning overall_status, rule_id, status, evidence, reason, suggested_fix, or target_agent.

  • Step 3: Normalize check statuses

In src/lib/domain/validation.ts, replace the current checkStatusSchema definition with this block near the existing enum definitions:

const checkStatusAliases: Record<string, CheckStatus> = {
  pass: "pass",
  passed: "pass",
  ok: "pass",
  "通过": "pass",
  "合格": "pass",
  warn: "warn",
  warning: "warn",
  "警告": "warn",
  "提醒": "warn",
  fail: "fail",
  failed: "fail",
  failure: "fail",
  "失败": "fail",
  "不通过": "fail",
};

function normalizeCheckStatus(value: unknown): unknown {
  const normalized = normalizedStringOrNull(value);
  if (!normalized) return value;
  return checkStatusAliases[normalized.toLowerCase()] ?? checkStatusAliases[normalized] ?? normalized;
}

export const checkStatusSchema = z.preprocess(
  normalizeCheckStatus,
  z.enum(["pass", "warn", "fail"]),
) satisfies z.ZodType<CheckStatus>;
  • Step 4: Normalize quality rule IDs

In src/lib/domain/validation.ts, replace the current qualityRuleIdSchema definition with:

const qualityRuleIdAliases: Record<string, QualityRuleId> = {
  industry_alignment: "industry_alignment",
  "行业对齐": "industry_alignment",
  "行业一致性": "industry_alignment",
  image_text_match: "image_text_match",
  "图文匹配": "image_text_match",
  "图片文本匹配": "image_text_match",
  voice_consistency: "voice_consistency",
  "语气一致性": "voice_consistency",
  "口吻一致性": "voice_consistency",
  platform_fit: "platform_fit",
  "平台适配": "platform_fit",
  company_name_integrity: "company_name_integrity",
  "公司名一致性": "company_name_integrity",
  "公司名称一致性": "company_name_integrity",
  title_quality: "title_quality",
  "标题质量": "title_quality",
  body_quality: "body_quality",
  "正文质量": "body_quality",
  hallucination_risk: "hallucination_risk",
  "幻觉风险": "hallucination_risk",
  "虚构风险": "hallucination_risk",
  claim_consistency: "claim_consistency",
  "事实一致性": "claim_consistency",
  "主张一致性": "claim_consistency",
  context_sensitive_terms: "context_sensitive_terms",
  "语境敏感词": "context_sensitive_terms",
  "敏感词": "context_sensitive_terms",
};

function normalizeQualityRuleId(value: unknown): unknown {
  const normalized = normalizedStringOrNull(value);
  if (!normalized) return value;
  return qualityRuleIdAliases[normalized] ?? normalized;
}

export const qualityRuleIdSchema = z.preprocess(
  normalizeQualityRuleId,
  z.enum([
    "industry_alignment",
    "image_text_match",
    "voice_consistency",
    "platform_fit",
    "company_name_integrity",
    "title_quality",
    "body_quality",
    "hallucination_risk",
    "claim_consistency",
    "context_sensitive_terms",
  ]),
) satisfies z.ZodType<QualityRuleId>;
  • Step 5: Normalize QA text fields and target agent

Add this helper block above qaCheckSchema in src/lib/domain/validation.ts:

function normalizeTargetAgent(value: unknown): unknown {
  const normalized = normalizedStringOrNull(value);
  if (!normalized) return null;
  const aliases: Record<string, string | null> = {
    none: null,
    null: null,
    "无": null,
    "无需": null,
    title: "title",
    "标题": "title",
    body: "body",
    "正文": "body",
    fact_card: "fact_card",
    factcard: "fact_card",
    "事实卡": "fact_card",
  };
  return aliases[normalized.toLowerCase()] ?? aliases[normalized] ?? normalized;
}

const targetAgentSchema = z.preprocess(
  normalizeTargetAgent,
  z.string().trim().min(1).nullable().default(null),
);

Replace qaCheckSchema with:

export const qaCheckSchema = z.object({
  rule_id: qualityRuleIdSchema,
  status: checkStatusSchema,
  evidence: requiredLlmStringSchema,
  reason: requiredLlmStringSchema,
  suggested_fix: optionalLlmStringSchema,
  target_agent: targetAgentSchema,
}) satisfies z.ZodType<QaCheck>;
  • Step 6: Run the QA compatibility test

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts -t "normalizes near-valid LLM QA report values"

Expected: PASS.

  • Step 7: Run all domain validation tests

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts

Expected: PASS for every test in domain validation.

  • Step 8: Commit Task 3

Run:

git add src/lib/domain/validation.ts src/lib/domain/__tests__/validation.test.ts
git commit -m "fix: 兼容QA检查LLM字段"

Task 4: Strengthen LLM Output Contracts in Prompts

Files:

  • Modify: src/lib/llm/prompts.ts

  • Test: src/lib/llm/__tests__/prompts.test.ts

  • Step 1: Write failing prompt contract tests

Add this test inside describe("LLM prompt builders", () => { ... }) in src/lib/llm/__tests__/prompts.test.ts, after the existing fact extraction prompt test:

  it("fact extraction prompt explicitly forbids object items in string arrays", () => {
    const prompt = `${FACT_EXTRACTOR_SYSTEM_PROMPT}\n${buildFactExtractorPrompt(articleInput)}`;

    expect(prompt).toContain("uncertain_items must be string[]");
    expect(prompt).toContain("core_claims must be string[]");
    expect(prompt).toContain("Do not return objects inside string arrays");
  });

Add this test after the existing article optimizer prompt test:

  it("article optimizer prompt explicitly describes optional array shapes", () => {
    const prompt = `${ARTICLE_OPTIMIZER_SYSTEM_PROMPT}\n${buildArticleOptimizerPrompt(articleInput, factCard)}`;

    expect(prompt).toContain("changed_sections must be string[]");
    expect(prompt).toContain("requires_user_confirmation must be string[]");
    expect(prompt).toContain("image_suggestions must be []");
  });

Add this test after the existing quality inspector prompt test:

  it("quality inspector prompt explicitly describes enum outputs", () => {
    const prompt = `${QUALITY_INSPECTOR_SYSTEM_PROMPT}\n${buildQualityInspectorPrompt({
      article: {
        title: "伟思德鲁 AIGC短视频培训",
        summary: "官网文章摘要",
        body_markdown: "正文",
        image_suggestions: [],
        changed_sections: [],
        requires_user_confirmation: [],
      },
      factCard,
      platform: "official_site",
      deterministicChecks: [
        {
          rule_id: "title_quality",
          status: "pass",
          evidence: "标题自然",
          reason: "本地规则通过",
          suggested_fix: "",
          target_agent: null,
        },
      ],
    })}`;

    expect(prompt).toContain("status must be one of pass, warn, fail");
    expect(prompt).toContain("rule_id must reuse the exact English rule_id");
    expect(prompt).toContain("target_agent must be title, body, fact_card, or null");
  });
  • Step 2: Run the failing prompt tests

Run:

npm test -- src/lib/llm/__tests__/prompts.test.ts

Expected: FAIL with missing expected prompt substrings such as uncertain_items must be string[].

  • Step 3: Add explicit output contracts to prompts

In src/lib/llm/prompts.ts, add these constants after PLATFORM_GUIDANCE and before formatPlatformGuidance:

const FACT_CARD_OUTPUT_CONTRACT = [
  "Output type contract:",
  "- company_full_name, target_industry, target_audience must be strings.",
  "- experience_years must be a number or null.",
  "- company_short_names, brand_names, product_names, core_claims, forbidden_claims, image_topics, uncertain_items must be string[].",
  "- core_claims must be string[].",
  "- uncertain_items must be string[].",
  "- Do not return objects inside string arrays; put the readable claim text directly in the array.",
  "Example:",
  JSON.stringify(
    {
      company_full_name: "示例科技有限公司",
      company_short_names: ["示例科技"],
      brand_names: ["示例品牌"],
      product_names: ["GEO内容优化平台"],
      target_industry: "GEO内容优化",
      target_audience: "市场团队",
      experience_years: null,
      core_claims: ["提供GEO内容优化服务"],
      forbidden_claims: ["行业第一"],
      image_topics: ["产品后台截图"],
      uncertain_items: ["客户案例缺少来源"],
    },
    null,
    2,
  ),
].join("\n");

const OPTIMIZED_ARTICLE_OUTPUT_CONTRACT = [
  "Output type contract:",
  "- title, summary, body_markdown must be strings.",
  "- image_suggestions must be [].",
  "- changed_sections must be string[].",
  "- requires_user_confirmation must be string[].",
  "- Do not return objects inside string arrays; put the readable item text directly in the array.",
  "Example:",
  JSON.stringify(
    {
      title: "示例科技 GEO 内容优化方案",
      summary: "围绕事实卡重写后的官网文章摘要。",
      body_markdown: "## 服务能力\n示例科技有限公司提供GEO内容优化服务。",
      image_suggestions: [],
      changed_sections: ["title", "body"],
      requires_user_confirmation: ["客户案例需要确认"],
    },
    null,
    2,
  ),
].join("\n");

const QA_OUTPUT_CONTRACT = [
  "Output type contract:",
  "- Return { checks: QaCheck[] }.",
  "- rule_id must reuse the exact English rule_id from deterministicChecks.",
  "- status must be one of pass, warn, fail.",
  "- evidence, reason, suggested_fix must be strings.",
  "- target_agent must be title, body, fact_card, or null.",
  "Example check:",
  JSON.stringify(
    {
      rule_id: "title_quality",
      status: "warn",
      evidence: "标题偏营销化",
      reason: "官网标题需要更克制",
      suggested_fix: "改成事实型标题",
      target_agent: "title",
    },
    null,
    2,
  ),
].join("\n");
  • Step 4: Insert output contracts into prompt builders

In buildFactExtractorPrompt, insert FACT_CARD_OUTPUT_CONTRACT after "Do not include confirmed_by_user.".

    "Do not include confirmed_by_user.",
    "",
    FACT_CARD_OUTPUT_CONTRACT,
    "",
    "字段要求:",

In buildArticleOptimizerPrompt, insert OPTIMIZED_ARTICLE_OUTPUT_CONTRACT after the exact-key list.

    "Return an OptimizedArticle JSON object with these exact keys:",
    "title, summary, body_markdown, image_suggestions, changed_sections, requires_user_confirmation.",
    "",
    OPTIMIZED_ARTICLE_OUTPUT_CONTRACT,
    "",
    formatPlatformGuidance(input.platform),

In buildQualityInspectorPrompt, insert QA_OUTPUT_CONTRACT after the first three lines.

    "Return a JSON object with a checks array.",
    "Each check must include rule_id, status, evidence, reason, suggested_fix, and target_agent.",
    "Only use rule_id values already present in deterministicChecks.",
    QA_OUTPUT_CONTRACT,
    "不得把 deterministic fail 降级。",

In buildTargetedRewritePrompt, insert OPTIMIZED_ARTICLE_OUTPUT_CONTRACT after "Return an OptimizedArticle JSON object.".

    "Return an OptimizedArticle JSON object.",
    OPTIMIZED_ARTICLE_OUTPUT_CONTRACT,
    "Rewrite only the fields needed for failedChecks.",
  • Step 5: Run prompt tests

Run:

npm test -- src/lib/llm/__tests__/prompts.test.ts

Expected: PASS for every test in LLM prompt builders.

  • Step 6: Commit Task 4

Run:

git add src/lib/llm/prompts.ts src/lib/llm/__tests__/prompts.test.ts
git commit -m "fix: 收紧LLM输出格式提示"

Task 5: Full Verification and Safety Checks

Files:

  • Verify: src/lib/domain/validation.ts

  • Verify: src/lib/domain/__tests__/validation.test.ts

  • Verify: src/lib/llm/prompts.ts

  • Verify: src/lib/llm/__tests__/prompts.test.ts

  • Step 1: Run targeted test suites

Run:

npm test -- src/lib/domain/__tests__/validation.test.ts src/lib/llm/__tests__/prompts.test.ts

Expected: PASS for both test files.

  • Step 2: Run all repository tests

Run:

npm test

Expected: PASS for the full Vitest suite.

  • Step 3: Run lint

Run:

npm run lint

Expected: PASS with no ESLint errors.

  • Step 4: Run production build

Run:

npm run build

Expected: PASS with a completed Next.js build.

  • Step 5: Run public-repo secret scan

Run:

rg -n "auth\\.token|secretKey|healthsource" . --glob '!node_modules/**' --glob '!.next/**' --glob '!.open-next/**' --glob '!deploy/*.toml'

Expected: no matches containing real credentials or local-only secrets.

  • Step 6: Inspect git status

Run:

git status --short

Expected: only the intended source and test files are modified by the implementation tasks. If .gitignore still appears as modified from before this plan, leave it untouched unless the user explicitly asks to include it.

Self-Review

  • Spec coverage: Task 1 covers fact-card schema drift including uncertain_items; Task 2 covers optimized article fields including requires_user_confirmation and image_suggestions; Task 3 covers QA labels and text fields; Task 4 reduces future drift through prompt contracts; Task 5 covers required verification.
  • Red-flag scan: The plan contains concrete files, code snippets, commands, and expected results for each task.
  • Type consistency: The helpers introduced in Task 1 are reused by Tasks 2 and 3; schema property names match src/lib/domain/types.ts; prompt contract names match existing builder functions.