diff --git a/docs/superpowers/plans/2026-06-24-llm-schema-compatibility.md b/docs/superpowers/plans/2026-06-24-llm-schema-compatibility.md new file mode 100644 index 0000000..210def5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-llm-schema-compatibility.md @@ -0,0 +1,918 @@ +# 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`. + +```ts + 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: + +```bash +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: + +```ts +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function firstStringField(record: Record, 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: + +```ts +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: + +```ts +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: + +```bash +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: + +```bash +npm test -- src/lib/domain/__tests__/validation.test.ts +``` + +Expected: PASS for every test in `domain validation`. + +- [ ] **Step 8: Commit Task 1** + +Run: + +```bash +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`. + +```ts + 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: + +```bash +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: + +```ts +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: + +```ts +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; +``` + +- [ ] **Step 5: Run the optimized-article compatibility test** + +Run: + +```bash +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: + +```bash +npm test -- src/lib/domain/__tests__/validation.test.ts +``` + +Expected: PASS for every test in `domain validation`. + +- [ ] **Step 7: Commit Task 2** + +Run: + +```bash +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`. + +```ts + 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: + +```bash +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: + +```ts +const checkStatusAliases: Record = { + 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; +``` + +- [ ] **Step 4: Normalize quality rule IDs** + +In `src/lib/domain/validation.ts`, replace the current `qualityRuleIdSchema` definition with: + +```ts +const qualityRuleIdAliases: Record = { + 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; +``` + +- [ ] **Step 5: Normalize QA text fields and target agent** + +Add this helper block above `qaCheckSchema` in `src/lib/domain/validation.ts`: + +```ts +function normalizeTargetAgent(value: unknown): unknown { + const normalized = normalizedStringOrNull(value); + if (!normalized) return null; + const aliases: Record = { + 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: + +```ts +export const qaCheckSchema = z.object({ + rule_id: qualityRuleIdSchema, + status: checkStatusSchema, + evidence: requiredLlmStringSchema, + reason: requiredLlmStringSchema, + suggested_fix: optionalLlmStringSchema, + target_agent: targetAgentSchema, +}) satisfies z.ZodType; +``` + +- [ ] **Step 6: Run the QA compatibility test** + +Run: + +```bash +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: + +```bash +npm test -- src/lib/domain/__tests__/validation.test.ts +``` + +Expected: PASS for every test in `domain validation`. + +- [ ] **Step 8: Commit Task 3** + +Run: + +```bash +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: + +```ts + 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: + +```ts + 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: + +```ts + 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: + +```bash +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`: + +```ts +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."`. + +```ts + "Do not include confirmed_by_user.", + "", + FACT_CARD_OUTPUT_CONTRACT, + "", + "字段要求:", +``` + +In `buildArticleOptimizerPrompt`, insert `OPTIMIZED_ARTICLE_OUTPUT_CONTRACT` after the exact-key list. + +```ts + "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. + +```ts + "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."`. + +```ts + "Return an OptimizedArticle JSON object.", + OPTIMIZED_ARTICLE_OUTPUT_CONTRACT, + "Rewrite only the fields needed for failedChecks.", +``` + +- [ ] **Step 5: Run prompt tests** + +Run: + +```bash +npm test -- src/lib/llm/__tests__/prompts.test.ts +``` + +Expected: PASS for every test in `LLM prompt builders`. + +- [ ] **Step 6: Commit Task 4** + +Run: + +```bash +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: + +```bash +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: + +```bash +npm test +``` + +Expected: PASS for the full Vitest suite. + +- [ ] **Step 3: Run lint** + +Run: + +```bash +npm run lint +``` + +Expected: PASS with no ESLint errors. + +- [ ] **Step 4: Run production build** + +Run: + +```bash +npm run build +``` + +Expected: PASS with a completed Next.js build. + +- [ ] **Step 5: Run public-repo secret scan** + +Run: + +```bash +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: + +```bash +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.