merge: 合并LLM schema兼容修复
This commit is contained in:
@@ -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<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:
|
||||||
|
|
||||||
|
```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<OptimizedArticle>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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<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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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.
|
||||||
@@ -19,6 +19,7 @@ The MVP is a local web application:
|
|||||||
7. Failed checks trigger targeted rewriting for up to two rounds.
|
7. Failed checks trigger targeted rewriting for up to two rounds.
|
||||||
8. User previews optimized content and QA report.
|
8. User previews optimized content and QA report.
|
||||||
9. User downloads Markdown and a basic Word document.
|
9. User downloads Markdown and a basic Word document.
|
||||||
|
10. User can optionally register where an optimized revision was published and later record real performance data for calibration.
|
||||||
|
|
||||||
## Explicitly Out Of Scope
|
## Explicitly Out Of Scope
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ The MVP is a local web application:
|
|||||||
- Direct `.docx` upload parsing.
|
- Direct `.docx` upload parsing.
|
||||||
- Complex Word template layout.
|
- Complex Word template layout.
|
||||||
- Automatic use of unconfirmed facts.
|
- Automatic use of unconfirmed facts.
|
||||||
|
- Automatic platform performance adapters in the first calibration release. The first release records performance manually while keeping an adapter interface for later.
|
||||||
|
|
||||||
## User Flow
|
## User Flow
|
||||||
|
|
||||||
@@ -236,6 +238,34 @@ Examples:
|
|||||||
- Delete or mark unsupported claims for hallucination risk.
|
- Delete or mark unsupported claims for hallucination risk.
|
||||||
- Warn instead of rewriting when image-text confidence is low.
|
- Warn instead of rewriting when image-text confidence is low.
|
||||||
|
|
||||||
|
### PerformanceCalibrator
|
||||||
|
|
||||||
|
Purpose: turn exported GEO articles into a measurable quality-improvement loop after publication.
|
||||||
|
|
||||||
|
This node is optional and runs after the optimization/export workflow. It does not rewrite the article, does not change the confirmed fact card, and does not block export. It records a pre-publication scoring snapshot, a publication record, post-publication performance snapshots, and calibration observations that can later improve GEO scoring rubrics.
|
||||||
|
|
||||||
|
Inputs:
|
||||||
|
|
||||||
|
- Optimized article revision.
|
||||||
|
- QA report for the same revision.
|
||||||
|
- Publish platform and optional URL.
|
||||||
|
- Manual performance data in the first release.
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
|
||||||
|
- `scoring_run`
|
||||||
|
- `publication_record`
|
||||||
|
- `performance_snapshot`
|
||||||
|
- `calibration_event`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Calibration is append-only for a published revision. Later data imports create new snapshots instead of overwriting earlier ones.
|
||||||
|
- The first release uses manual data entry only.
|
||||||
|
- The service boundary must support future adapters that return the same `PerformanceSnapshot` shape.
|
||||||
|
- Adapter code must never store platform cookies, tokens, or login state in the public repository.
|
||||||
|
- Calibration observations can recommend rubric changes, but rubric changes require a separate reviewed migration or plan.
|
||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
| Rule ID | Issue Prevented | First Version Behavior |
|
| Rule ID | Issue Prevented | First Version Behavior |
|
||||||
@@ -272,9 +302,108 @@ Examples:
|
|||||||
- Useless content.
|
- Useless content.
|
||||||
- Third-party voice when platform is official site.
|
- Third-party voice when platform is official site.
|
||||||
|
|
||||||
|
## Publication Performance Calibration
|
||||||
|
|
||||||
|
The project can borrow the useful part of `cheat-on-content`: content quality should become a measurable loop, not a one-time rewrite. GEO's version keeps the web app and database model, and adds a productized calibration layer instead of copying the external skill's file-based workflow.
|
||||||
|
|
||||||
|
First-release flow:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["Optimized Article Revision"] --> B["Pre-Publish Scoring"]
|
||||||
|
B --> C["Publication Record"]
|
||||||
|
C --> D["Manual Performance Snapshot"]
|
||||||
|
D --> E["Calibration Event"]
|
||||||
|
E --> F["Rubric Improvement Backlog"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Publish Scoring
|
||||||
|
|
||||||
|
Before or after export, the system can score an optimized revision against a GEO rubric. The first rubric should focus on business article quality rather than viral-video prediction.
|
||||||
|
|
||||||
|
Suggested first dimensions:
|
||||||
|
|
||||||
|
| Dimension | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `fact_integrity` | Whether names, products, years, cases, and claims stay inside the confirmed fact card. |
|
||||||
|
| `platform_fit` | Whether the output matches official site, media article, comparison review, or recommendation list expectations. |
|
||||||
|
| `search_intent_fit` | Whether the article answers the likely GEO/search intent behind the topic. |
|
||||||
|
| `answer_density` | Whether the article gives useful, specific information instead of vague promotional filler. |
|
||||||
|
| `trust_signal_quality` | Whether credibility signals are specific, sourced, and not exaggerated. |
|
||||||
|
| `readability` | Whether title, summary, and body are clear enough for customers and AI answer engines. |
|
||||||
|
|
||||||
|
The score is stored as a snapshot. It is not a replacement for QA gates. QA gates protect factual safety; scoring provides a baseline for later performance learning.
|
||||||
|
|
||||||
|
### Publication Records
|
||||||
|
|
||||||
|
A publication record links one optimized article revision to where it was published.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- Job ID.
|
||||||
|
- Optimized revision.
|
||||||
|
- Platform.
|
||||||
|
- URL.
|
||||||
|
- Published at.
|
||||||
|
- Publication notes.
|
||||||
|
- Status: draft, published, archived.
|
||||||
|
|
||||||
|
The same optimized revision may have multiple publication records if a user republishes it on multiple channels.
|
||||||
|
|
||||||
|
### Manual Performance Snapshots
|
||||||
|
|
||||||
|
The first release records post-publication performance manually. This avoids platform login, anti-scraping, and credential risk while validating the calibration loop.
|
||||||
|
|
||||||
|
Baseline fields:
|
||||||
|
|
||||||
|
- Views or reads.
|
||||||
|
- Impressions, when available.
|
||||||
|
- Clicks or inquiry actions, when available.
|
||||||
|
- Likes, comments, shares, saves, when available.
|
||||||
|
- Average ranking or citation position, when the user can observe it.
|
||||||
|
- Snapshot window, such as T+1d, T+3d, T+7d, or custom.
|
||||||
|
- Comment or feedback summary.
|
||||||
|
- Data source: `manual`.
|
||||||
|
|
||||||
|
Manual snapshots should allow missing metrics. Different platforms expose different numbers, and forcing fake zeroes would corrupt later calibration.
|
||||||
|
|
||||||
|
### Adapter Boundary
|
||||||
|
|
||||||
|
Future adapters must write the same performance shape as manual entry:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PerformanceAdapter {
|
||||||
|
source: string;
|
||||||
|
fetch(input: AdapterFetchInput): Promise<PerformanceSnapshot>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter output is normalized before storage:
|
||||||
|
|
||||||
|
- `source`: `manual`, `adapter:xhs`, `adapter:bilibili`, `adapter:wechat`, or similar.
|
||||||
|
- `metrics`: sparse numeric metrics.
|
||||||
|
- `snapshot_at`: ISO timestamp.
|
||||||
|
- `window_label`: human label such as `T+3d`.
|
||||||
|
- `raw_reference`: optional safe reference to adapter output, never raw cookies or credentials.
|
||||||
|
|
||||||
|
The first implementation should include a `manual` adapter only. Platform adapters are later work and must keep credentials out of Git, D1, logs, and public artifacts.
|
||||||
|
|
||||||
|
### Calibration Events
|
||||||
|
|
||||||
|
A calibration event compares the pre-publish scoring snapshot, QA report, and performance snapshot.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- High `fact_integrity` and high `answer_density` correlate with stronger inquiry clicks.
|
||||||
|
- Weak `platform_fit` correlates with poor engagement on media articles.
|
||||||
|
- QA warning on `hallucination_risk` did not affect traffic but increased manual review burden.
|
||||||
|
- Articles with high readability but low trust-signal quality received views but no inquiries.
|
||||||
|
|
||||||
|
Calibration events should be written as observations, not automatic rubric changes. A later rubric update must be reviewed separately and applied through migrations/tests so historical data remains interpretable.
|
||||||
|
|
||||||
## Data Model
|
## Data Model
|
||||||
|
|
||||||
The first version uses local SQLite plus an export folder.
|
The first version uses local SQLite plus an export folder. Cloudflare deployments use D1/R2 bindings with migration-only schema changes; local and online data remain explicitly separated.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/
|
data/
|
||||||
@@ -392,6 +521,115 @@ Quality checks for one revision.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `rubric_version`
|
||||||
|
|
||||||
|
A versioned scoring rubric for GEO article performance calibration.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "rubric_geo_v1",
|
||||||
|
"version": "v1",
|
||||||
|
"name": "GEO article performance rubric",
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"id": "fact_integrity",
|
||||||
|
"label": "事实一致性",
|
||||||
|
"weight": 2,
|
||||||
|
"description": "事实、公司名、产品名和经验年限是否遵守事实卡"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"formula": "weighted_average_0_to_10",
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": "2026-06-24T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `scoring_run`
|
||||||
|
|
||||||
|
A scoring snapshot for one optimized article revision.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "score_xxx",
|
||||||
|
"job_id": "job_xxx",
|
||||||
|
"revision": 2,
|
||||||
|
"rubric_version_id": "rubric_geo_v1",
|
||||||
|
"dimension_scores": {
|
||||||
|
"fact_integrity": 5,
|
||||||
|
"platform_fit": 4,
|
||||||
|
"search_intent_fit": 4,
|
||||||
|
"answer_density": 3,
|
||||||
|
"trust_signal_quality": 3,
|
||||||
|
"readability": 4
|
||||||
|
},
|
||||||
|
"composite_score": 7.8,
|
||||||
|
"rationale": "事实一致性强,平台适配较好,但信任信号仍偏泛。",
|
||||||
|
"created_at": "2026-06-24T10:05:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `publication_record`
|
||||||
|
|
||||||
|
A publication instance for an optimized revision.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "pub_xxx",
|
||||||
|
"job_id": "job_xxx",
|
||||||
|
"revision": 2,
|
||||||
|
"platform": "official_site",
|
||||||
|
"url": "https://example.com/articles/geo-optimization",
|
||||||
|
"published_at": "2026-06-24T12:00:00+08:00",
|
||||||
|
"status": "published",
|
||||||
|
"notes": "官网文章首发"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `performance_snapshot`
|
||||||
|
|
||||||
|
One post-publication performance measurement.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "perf_xxx",
|
||||||
|
"publication_id": "pub_xxx",
|
||||||
|
"source": "manual",
|
||||||
|
"window_label": "T+7d",
|
||||||
|
"metrics": {
|
||||||
|
"views": 1200,
|
||||||
|
"impressions": 4300,
|
||||||
|
"clicks": 86,
|
||||||
|
"inquiries": 7,
|
||||||
|
"likes": 18,
|
||||||
|
"comments": 3,
|
||||||
|
"shares": 5,
|
||||||
|
"saves": 11
|
||||||
|
},
|
||||||
|
"feedback_summary": "用户主要询问服务流程和案例真实性。",
|
||||||
|
"snapshot_at": "2026-07-01T12:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `calibration_event`
|
||||||
|
|
||||||
|
An observation linking scoring, QA, and real performance.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "cal_xxx",
|
||||||
|
"publication_id": "pub_xxx",
|
||||||
|
"scoring_run_id": "score_xxx",
|
||||||
|
"performance_snapshot_id": "perf_xxx",
|
||||||
|
"direction": "better_than_expected",
|
||||||
|
"observations": [
|
||||||
|
"高 answer_density 的段落带来更多服务流程咨询。",
|
||||||
|
"trust_signal_quality 偏低,用户仍追问案例依据。"
|
||||||
|
],
|
||||||
|
"recommended_action": "后续 rubric 提高 trust_signal_quality 权重前,先积累至少 5 篇同类样本。",
|
||||||
|
"created_at": "2026-07-01T12:10:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
### Fact Extraction
|
### Fact Extraction
|
||||||
@@ -408,10 +646,18 @@ Examples:
|
|||||||
|
|
||||||
### QA Failure
|
### QA Failure
|
||||||
|
|
||||||
Hard failures block export. Warnings allow export with visible confirmation prompts.
|
QA failures do not hide exports. Hard failures and warnings are surfaced as risk signals with visible confirmation prompts, so the user can still download artifacts for review or customer handoff.
|
||||||
|
|
||||||
Failed checks trigger targeted rewrite for up to two rounds. After two failed rounds, the app stops rewriting and shows manual review fields.
|
Failed checks trigger targeted rewrite for up to two rounds. After two failed rounds, the app stops rewriting and shows manual review fields.
|
||||||
|
|
||||||
|
### Performance Data
|
||||||
|
|
||||||
|
Manual performance snapshots accept sparse metrics. Missing metrics are stored as absent values, not zeroes.
|
||||||
|
|
||||||
|
Adapter failures must degrade to manual entry. The app should show the source and failure reason, but it must not block the user from recording performance data manually.
|
||||||
|
|
||||||
|
Calibration events never rewrite published articles automatically. They create a reviewable backlog for future rubric changes.
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
The MVP is complete when:
|
The MVP is complete when:
|
||||||
@@ -421,8 +667,12 @@ The MVP is complete when:
|
|||||||
3. Confirmed fact card can be saved and reused as a local brand template.
|
3. Confirmed fact card can be saved and reused as a local brand template.
|
||||||
4. System can generate an optimized article without changing confirmed facts.
|
4. System can generate an optimized article without changing confirmed facts.
|
||||||
5. System can generate a structured QA report for the 10 quality gates.
|
5. System can generate a structured QA report for the 10 quality gates.
|
||||||
6. Hard failures block export until fixed or manually reviewed.
|
6. Hard failures and warnings are visible in the QA report without hiding export links.
|
||||||
7. User can download Markdown and a basic Word document.
|
7. User can download Markdown and a basic Word document.
|
||||||
|
8. User can create a publication record for an optimized revision.
|
||||||
|
9. User can manually record a post-publication performance snapshot.
|
||||||
|
10. System can create a calibration event that compares score, QA findings, and performance.
|
||||||
|
11. The performance collection boundary can later support platform adapters without changing the stored snapshot shape.
|
||||||
|
|
||||||
## Minimum Test Samples
|
## Minimum Test Samples
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,42 @@ describe("domain validation", () => {
|
|||||||
expect(numericExperience.experience_years).toBe(8);
|
expect(numericExperience.experience_years).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps incomplete candidate fact cards editable and ready", () => {
|
it("keeps incomplete candidate fact cards editable and ready", () => {
|
||||||
const parsed = candidateFactCardSchema.parse({
|
const parsed = candidateFactCardSchema.parse({
|
||||||
company_full_name: "",
|
company_full_name: "",
|
||||||
@@ -146,6 +182,40 @@ describe("domain validation", () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes object-shaped changed sections from LLM output", () => {
|
it("normalizes object-shaped changed sections from LLM output", () => {
|
||||||
const parsed = optimizedArticleSchema.parse({
|
const parsed = optimizedArticleSchema.parse({
|
||||||
title: "Optimized article",
|
title: "Optimized article",
|
||||||
@@ -164,4 +234,35 @@ describe("domain validation", () => {
|
|||||||
"body: Fixed sentence flow.",
|
"body: Fixed sentence flow.",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(["客户案例"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+276
-54
@@ -20,24 +20,89 @@ export const publishPlatformSchema = z.enum([
|
|||||||
"recommendation_list",
|
"recommendation_list",
|
||||||
]) satisfies z.ZodType<PublishPlatform>;
|
]) satisfies z.ZodType<PublishPlatform>;
|
||||||
|
|
||||||
export const checkStatusSchema = z.enum([
|
const checkStatusAliases: Record<string, CheckStatus> = {
|
||||||
"pass",
|
pass: "pass",
|
||||||
"warn",
|
passed: "pass",
|
||||||
"fail",
|
ok: "pass",
|
||||||
]) satisfies z.ZodType<CheckStatus>;
|
"通过": "pass",
|
||||||
|
"合格": "pass",
|
||||||
|
warn: "warn",
|
||||||
|
warning: "warn",
|
||||||
|
"警告": "warn",
|
||||||
|
"提醒": "warn",
|
||||||
|
fail: "fail",
|
||||||
|
failed: "fail",
|
||||||
|
failure: "fail",
|
||||||
|
"失败": "fail",
|
||||||
|
"不通过": "fail",
|
||||||
|
};
|
||||||
|
|
||||||
export const qualityRuleIdSchema = z.enum([
|
function normalizeCheckStatus(value: unknown): unknown {
|
||||||
"industry_alignment",
|
const normalized = normalizedStringOrNull(value);
|
||||||
"image_text_match",
|
if (!normalized) return value;
|
||||||
"voice_consistency",
|
return (
|
||||||
"platform_fit",
|
checkStatusAliases[normalized.toLowerCase()] ??
|
||||||
"company_name_integrity",
|
checkStatusAliases[normalized] ??
|
||||||
"title_quality",
|
normalized
|
||||||
"body_quality",
|
);
|
||||||
"hallucination_risk",
|
}
|
||||||
"claim_consistency",
|
|
||||||
"context_sensitive_terms",
|
export const checkStatusSchema = z.preprocess(
|
||||||
]) satisfies z.ZodType<QualityRuleId>;
|
normalizeCheckStatus,
|
||||||
|
z.enum(["pass", "warn", "fail"]),
|
||||||
|
) satisfies z.ZodType<CheckStatus>;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
export const imageInputSchema = z.object({
|
export const imageInputSchema = z.object({
|
||||||
type: z.enum(["description", "link"]),
|
type: z.enum(["description", "link"]),
|
||||||
@@ -52,19 +117,129 @@ export const articleInputSchema = z.object({
|
|||||||
user_instructions: z.string().trim().default(""),
|
user_instructions: z.string().trim().default(""),
|
||||||
}) satisfies z.ZodType<ArticleInput>;
|
}) satisfies z.ZodType<ArticleInput>;
|
||||||
|
|
||||||
const stringOrStringArraySchema = z.preprocess((value) => {
|
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)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
.map(normalizedStringOrNull)
|
||||||
.filter(Boolean)
|
.filter((item): item is string => Boolean(item))
|
||||||
.join("、");
|
.join("、");
|
||||||
}
|
}
|
||||||
return value;
|
if (!isPlainRecord(value)) return value;
|
||||||
}, z.string().trim());
|
|
||||||
|
|
||||||
const experienceYearsSchema = z.preprocess((value) => {
|
const direct = firstStringField(value, llmStringKeys);
|
||||||
if (typeof value !== "string") return value;
|
if (direct) return direct;
|
||||||
const trimmed = value.trim();
|
|
||||||
|
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([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
if (
|
||||||
trimmed === "" ||
|
trimmed === "" ||
|
||||||
/^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test(
|
/^(?:unknown|none|null|n\/a|not\s+specified|不详|不明确|未知|无)$/i.test(
|
||||||
@@ -74,21 +249,26 @@ const experienceYearsSchema = z.preprocess((value) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const yearMatch = trimmed.match(/\d{1,3}/);
|
const yearMatch = trimmed.match(/\d{1,3}/);
|
||||||
return yearMatch ? Number(yearMatch[0]) : value;
|
return yearMatch ? Number(yearMatch[0]) : candidate;
|
||||||
}, z.number().int().nonnegative().nullable().default(null));
|
}
|
||||||
|
|
||||||
|
const experienceYearsSchema = z.preprocess(
|
||||||
|
normalizeExperienceYears,
|
||||||
|
z.number().int().nonnegative().nullable().default(null),
|
||||||
|
);
|
||||||
|
|
||||||
const factCardBaseSchema = z.object({
|
const factCardBaseSchema = z.object({
|
||||||
company_full_name: z.string().trim(),
|
company_full_name: llmStringSchema,
|
||||||
company_short_names: z.array(z.string().trim().min(1)).default([]),
|
company_short_names: stringListSchema,
|
||||||
brand_names: z.array(z.string().trim().min(1)).default([]),
|
brand_names: stringListSchema,
|
||||||
product_names: z.array(z.string().trim().min(1)).default([]),
|
product_names: stringListSchema,
|
||||||
target_industry: z.string().trim(),
|
target_industry: llmStringSchema,
|
||||||
target_audience: stringOrStringArraySchema,
|
target_audience: llmStringSchema,
|
||||||
experience_years: experienceYearsSchema,
|
experience_years: experienceYearsSchema,
|
||||||
core_claims: z.array(z.string().trim().min(1)).default([]),
|
core_claims: stringListSchema,
|
||||||
forbidden_claims: z.array(z.string().trim().min(1)).default([]),
|
forbidden_claims: stringListSchema,
|
||||||
image_topics: z.array(z.string().trim().min(1)).default([]),
|
image_topics: stringListSchema,
|
||||||
uncertain_items: z.array(z.string().trim().min(1)).default([]),
|
uncertain_items: stringListSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const candidateFactCardSchema = factCardBaseSchema
|
export const candidateFactCardSchema = factCardBaseSchema
|
||||||
@@ -117,16 +297,27 @@ export const imageSuggestionSchema = z.object({
|
|||||||
suggestion: z.string().trim().min(1),
|
suggestion: z.string().trim().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
function firstStringField(record: Record<string, unknown>, keys: string[]) {
|
function normalizeImageSuggestions(value: unknown): unknown {
|
||||||
for (const key of keys) {
|
if (!Array.isArray(value)) return [];
|
||||||
const value = record[key];
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
return value.flatMap((item) => {
|
||||||
return value.trim();
|
if (!isPlainRecord(item)) return [];
|
||||||
}
|
const source = normalizedStringOrNull(
|
||||||
}
|
item.source ?? item.image ?? item.name ?? item.title,
|
||||||
return null;
|
);
|
||||||
|
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([]),
|
||||||
|
);
|
||||||
|
|
||||||
function normalizeChangedSection(value: unknown) {
|
function normalizeChangedSection(value: unknown) {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -174,21 +365,52 @@ const changedSectionSchema = z.preprocess(
|
|||||||
export const optimizedArticleSchema = z.object({
|
export const optimizedArticleSchema = z.object({
|
||||||
job_id: z.string().trim().min(1).optional(),
|
job_id: z.string().trim().min(1).optional(),
|
||||||
revision: z.number().int().positive().optional(),
|
revision: z.number().int().positive().optional(),
|
||||||
title: z.string().trim().min(1),
|
title: requiredLlmStringSchema,
|
||||||
summary: z.string().trim().min(1),
|
summary: requiredLlmStringSchema,
|
||||||
body_markdown: z.string().trim().min(1),
|
body_markdown: requiredLlmStringSchema,
|
||||||
image_suggestions: z.array(imageSuggestionSchema).default([]),
|
image_suggestions: imageSuggestionsSchema,
|
||||||
changed_sections: z.array(changedSectionSchema).default([]),
|
changed_sections: z.array(changedSectionSchema).default([]),
|
||||||
requires_user_confirmation: z.array(z.string().trim().min(1)).default([]),
|
requires_user_confirmation: stringListSchema,
|
||||||
}) satisfies z.ZodType<OptimizedArticle>;
|
}) satisfies z.ZodType<OptimizedArticle>;
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
const normalizedKey = normalized.toLowerCase();
|
||||||
|
if (Object.prototype.hasOwnProperty.call(aliases, normalizedKey)) {
|
||||||
|
return aliases[normalizedKey];
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(aliases, normalized)) {
|
||||||
|
return aliases[normalized];
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAgentSchema = z.preprocess(
|
||||||
|
normalizeTargetAgent,
|
||||||
|
z.string().trim().min(1).nullable().default(null),
|
||||||
|
);
|
||||||
|
|
||||||
export const qaCheckSchema = z.object({
|
export const qaCheckSchema = z.object({
|
||||||
rule_id: qualityRuleIdSchema,
|
rule_id: qualityRuleIdSchema,
|
||||||
status: checkStatusSchema,
|
status: checkStatusSchema,
|
||||||
evidence: z.string().trim().min(1),
|
evidence: requiredLlmStringSchema,
|
||||||
reason: z.string().trim().min(1),
|
reason: requiredLlmStringSchema,
|
||||||
suggested_fix: z.string().trim().default(""),
|
suggested_fix: optionalLlmStringSchema,
|
||||||
target_agent: z.string().trim().min(1).nullable().default(null),
|
target_agent: targetAgentSchema,
|
||||||
}) satisfies z.ZodType<QaCheck>;
|
}) satisfies z.ZodType<QaCheck>;
|
||||||
|
|
||||||
export const qaReportSchema = z.object({
|
export const qaReportSchema = z.object({
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ describe("LLM prompt builders", () => {
|
|||||||
expect(prompt).toContain("图片主题");
|
expect(prompt).toContain("图片主题");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
it("article optimizer prompt includes platform templates and Chinese output rules", () => {
|
it("article optimizer prompt includes platform templates and Chinese output rules", () => {
|
||||||
const prompt = `${ARTICLE_OPTIMIZER_SYSTEM_PROMPT}\n${buildArticleOptimizerPrompt(articleInput, factCard)}`;
|
const prompt = `${ARTICLE_OPTIMIZER_SYSTEM_PROMPT}\n${buildArticleOptimizerPrompt(articleInput, factCard)}`;
|
||||||
|
|
||||||
@@ -60,6 +68,14 @@ describe("LLM prompt builders", () => {
|
|||||||
expect(prompt).toContain("forbidden_claims");
|
expect(prompt).toContain("forbidden_claims");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 []");
|
||||||
|
});
|
||||||
|
|
||||||
it("quality inspector prompt encodes fail and warn standards for all risk gates", () => {
|
it("quality inspector prompt encodes fail and warn standards for all risk gates", () => {
|
||||||
const prompt = `${QUALITY_INSPECTOR_SYSTEM_PROMPT}\n${buildQualityInspectorPrompt({
|
const prompt = `${QUALITY_INSPECTOR_SYSTEM_PROMPT}\n${buildQualityInspectorPrompt({
|
||||||
article: {
|
article: {
|
||||||
@@ -92,6 +108,35 @@ describe("LLM prompt builders", () => {
|
|||||||
expect(prompt).toContain("warn 标准");
|
expect(prompt).toContain("warn 标准");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
it("targeted rewrite prompt gives rule-specific repair actions", () => {
|
it("targeted rewrite prompt gives rule-specific repair actions", () => {
|
||||||
const prompt = `${TARGETED_REWRITER_SYSTEM_PROMPT}\n${buildTargetedRewritePrompt({
|
const prompt = `${TARGETED_REWRITER_SYSTEM_PROMPT}\n${buildTargetedRewritePrompt({
|
||||||
article: {
|
article: {
|
||||||
|
|||||||
@@ -25,6 +25,78 @@ const PLATFORM_GUIDANCE: Record<PublishPlatform, string> = {
|
|||||||
"recommendation_list / 推荐榜单:推荐榜单口吻,必须解释推荐依据;不得制造榜单排名、奖项或第三方认证。",
|
"recommendation_list / 推荐榜单:推荐榜单口吻,必须解释推荐依据;不得制造榜单排名、奖项或第三方认证。",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
function formatPlatformGuidance(selected: PublishPlatform) {
|
function formatPlatformGuidance(selected: PublishPlatform) {
|
||||||
return [
|
return [
|
||||||
"平台模板选项:",
|
"平台模板选项:",
|
||||||
@@ -69,6 +141,8 @@ export function buildFactExtractorPrompt(input: ArticleInput) {
|
|||||||
"company_full_name, company_short_names, brand_names, product_names, target_industry, target_audience, experience_years, core_claims, forbidden_claims, image_topics, uncertain_items.",
|
"company_full_name, company_short_names, brand_names, product_names, target_industry, target_audience, experience_years, core_claims, forbidden_claims, image_topics, uncertain_items.",
|
||||||
"Do not include confirmed_by_user.",
|
"Do not include confirmed_by_user.",
|
||||||
"",
|
"",
|
||||||
|
FACT_CARD_OUTPUT_CONTRACT,
|
||||||
|
"",
|
||||||
"字段要求:",
|
"字段要求:",
|
||||||
"- core_claims 只放原文明示、可作为后续优化硬约束的事实。",
|
"- core_claims 只放原文明示、可作为后续优化硬约束的事实。",
|
||||||
"- forbidden_claims 放行业第一、最强、知名客户、显著提升、保证转化等无法验证或高风险主张。",
|
"- forbidden_claims 放行业第一、最强、知名客户、显著提升、保证转化等无法验证或高风险主张。",
|
||||||
@@ -89,6 +163,8 @@ export function buildArticleOptimizerPrompt(
|
|||||||
"Return an OptimizedArticle JSON object with these exact keys:",
|
"Return an OptimizedArticle JSON object with these exact keys:",
|
||||||
"title, summary, body_markdown, image_suggestions, changed_sections, requires_user_confirmation.",
|
"title, summary, body_markdown, image_suggestions, changed_sections, requires_user_confirmation.",
|
||||||
"",
|
"",
|
||||||
|
OPTIMIZED_ARTICLE_OUTPUT_CONTRACT,
|
||||||
|
"",
|
||||||
formatPlatformGuidance(input.platform),
|
formatPlatformGuidance(input.platform),
|
||||||
"",
|
"",
|
||||||
"输出要求:",
|
"输出要求:",
|
||||||
@@ -119,6 +195,7 @@ export function buildQualityInspectorPrompt(input: {
|
|||||||
"Return a JSON object with a checks array.",
|
"Return a JSON object with a checks array.",
|
||||||
"Each check must include rule_id, status, evidence, reason, suggested_fix, and target_agent.",
|
"Each check must include rule_id, status, evidence, reason, suggested_fix, and target_agent.",
|
||||||
"Only use rule_id values already present in deterministicChecks.",
|
"Only use rule_id values already present in deterministicChecks.",
|
||||||
|
QA_OUTPUT_CONTRACT,
|
||||||
"不得把 deterministic fail 降级。",
|
"不得把 deterministic fail 降级。",
|
||||||
"",
|
"",
|
||||||
formatPlatformGuidance(input.platform),
|
formatPlatformGuidance(input.platform),
|
||||||
@@ -146,6 +223,7 @@ export function buildTargetedRewritePrompt(input: {
|
|||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
"Return an OptimizedArticle JSON object.",
|
"Return an OptimizedArticle JSON object.",
|
||||||
|
OPTIMIZED_ARTICLE_OUTPUT_CONTRACT,
|
||||||
"Rewrite only the fields needed for failedChecks.",
|
"Rewrite only the fields needed for failedChecks.",
|
||||||
"",
|
"",
|
||||||
"按 rule_id 执行修复动作:",
|
"按 rule_id 执行修复动作:",
|
||||||
|
|||||||
Reference in New Issue
Block a user