Files
GEOAgentArticleOptimizer/docs/superpowers/plans/2026-06-24-publication-performance-calibration.md
T

70 KiB
Raw Blame History

Publication Performance Calibration 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: Build the first publication performance calibration loop: score optimized revisions, register publications, manually record performance snapshots, and generate calibration observations while keeping a future adapter boundary.

Architecture: Add a focused calibration domain beside the existing optimization workflow. Persist calibration data through the existing repository abstraction so local SQLite and Cloudflare D1 stay aligned. The first implementation uses a manual performance adapter only; future platform adapters will normalize into the same PerformanceSnapshot shape.

Tech Stack: Next.js 16 App Router, React 19, TypeScript, Zod 4, Vitest, better-sqlite3, Cloudflare D1, existing API key guard.


File Structure

  • Create src/lib/calibration/types.ts: domain types for rubric versions, scoring runs, publication records, sparse performance snapshots, adapter input/output, and calibration events.
  • Create src/lib/calibration/validation.ts: Zod schemas for API inputs and stored calibration objects.
  • Create src/lib/calibration/scoring.ts: deterministic v1 GEO scoring rubric and calibration event generation.
  • Create src/lib/calibration/manual-adapter.ts: first PerformanceAdapter implementation that normalizes user-entered metrics.
  • Create src/lib/calibration/__tests__/validation.test.ts: validation tests for sparse metrics and adapter-safe snapshots.
  • Create src/lib/calibration/__tests__/scoring.test.ts: scoring and calibration-event tests.
  • Modify src/lib/db/schema.ts: add local SQLite tables.
  • Modify migrations/0002_publication_performance_calibration.sql: add D1 migration with the same tables.
  • Modify src/lib/db/repositories.ts: add row types and SQLite helpers.
  • Modify src/lib/db/repository.ts: extend AppRepository with calibration methods.
  • Modify src/lib/db/sqlite-repository.ts: expose calibration methods.
  • Modify src/lib/db/d1-repository.ts: expose calibration methods for Cloudflare.
  • Modify src/lib/db/__tests__/repository.test.ts: test local persistence round trip.
  • Modify src/lib/db/__tests__/d1-repository.test.ts: test D1 SQL/binds for one representative write.
  • Create src/app/api/jobs/[jobId]/calibration/score/route.ts: create a scoring run for latest optimized revision.
  • Create src/app/api/jobs/[jobId]/publications/route.ts: create and list publication records for a job.
  • Create src/app/api/publications/[publicationId]/performance/route.ts: record a manual performance snapshot and calibration event.
  • Modify src/app/api/__tests__/jobs.test.ts: route-level coverage for score, publication, and performance endpoints.
  • Create src/components/performance-calibration-panel.tsx: compact UI for score, publication registration, and manual snapshot entry.
  • Modify src/app/page.tsx: wire the calibration panel after optimization.
  • Modify src/app/globals.css: add small calibration form/result styles.

Task 1: Calibration Domain Types And Validation

Files:

  • Create: src/lib/calibration/types.ts

  • Create: src/lib/calibration/validation.ts

  • Create: src/lib/calibration/__tests__/validation.test.ts

  • Step 1: Write failing validation tests

Create src/lib/calibration/__tests__/validation.test.ts:

import { describe, expect, it } from "vitest";

import {
  manualPerformanceInputSchema,
  publicationInputSchema,
  performanceSnapshotSchema,
} from "../validation";

describe("calibration validation", () => {
  it("accepts a publication URL and normalizes optional notes", () => {
    const parsed = publicationInputSchema.parse({
      platform: "official_site",
      url: "https://example.com/articles/geo",
      published_at: "2026-06-24T12:00:00.000Z",
      notes: " 官网首发 ",
    });

    expect(parsed.platform).toBe("official_site");
    expect(parsed.notes).toBe("官网首发");
  });

  it("keeps sparse manual metrics absent instead of turning them into zeroes", () => {
    const parsed = manualPerformanceInputSchema.parse({
      window_label: "T+7d",
      views: "1200",
      clicks: "",
      inquiries: undefined,
      comments: "3",
      feedback_summary: "用户追问案例依据",
    });

    expect(parsed.metrics).toEqual({
      views: 1200,
      comments: 3,
    });
    expect(parsed.feedback_summary).toBe("用户追问案例依据");
  });

  it("rejects unsafe raw adapter credentials in snapshots", () => {
    expect(() =>
      performanceSnapshotSchema.parse({
        id: "perf_test",
        publication_id: "pub_test",
        source: "manual",
        window_label: "T+3d",
        metrics: { views: 10 },
        feedback_summary: "",
        raw_reference: "cookie=sessionid=secret",
        snapshot_at: "2026-06-24T12:00:00.000Z",
      }),
    ).toThrow(/raw_reference/);
  });
});
  • Step 2: Run the failing validation tests

Run:

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

Expected: FAIL because src/lib/calibration/validation.ts does not exist.

  • Step 3: Add calibration domain types

Create src/lib/calibration/types.ts:

import type { PublishPlatform, QaReport } from "../domain/types";

export type CalibrationDirection =
  | "better_than_expected"
  | "as_expected"
  | "worse_than_expected"
  | "needs_more_data";

export type PerformanceSource =
  | "manual"
  | `adapter:${string}`;

export interface RubricDimension {
  id: string;
  label: string;
  weight: number;
  description: string;
}

export interface RubricVersion {
  id: string;
  version: string;
  name: string;
  dimensions: RubricDimension[];
  formula: "weighted_average_0_to_10";
  is_active: boolean;
  created_at: string;
}

export interface ScoringRun {
  id: string;
  job_id: string;
  revision: number;
  rubric_version_id: string;
  dimension_scores: Record<string, number>;
  composite_score: number;
  rationale: string;
  created_at: string;
}

export interface PublicationRecord {
  id: string;
  job_id: string;
  revision: number;
  platform: PublishPlatform;
  url: string;
  published_at: string;
  status: "draft" | "published" | "archived";
  notes: string;
  created_at: string;
  updated_at: string;
}

export interface PerformanceMetrics {
  views?: number;
  impressions?: number;
  clicks?: number;
  inquiries?: number;
  likes?: number;
  comments?: number;
  shares?: number;
  saves?: number;
  average_position?: number;
}

export interface PerformanceSnapshot {
  id: string;
  publication_id: string;
  source: PerformanceSource;
  window_label: string;
  metrics: PerformanceMetrics;
  feedback_summary: string;
  raw_reference?: string;
  snapshot_at: string;
}

export interface CalibrationEvent {
  id: string;
  publication_id: string;
  scoring_run_id: string;
  performance_snapshot_id: string;
  direction: CalibrationDirection;
  observations: string[];
  recommended_action: string;
  created_at: string;
}

export interface AdapterFetchInput {
  publication: PublicationRecord;
  window_label: string;
}

export interface PerformanceAdapter {
  source: PerformanceSource;
  fetch(input: AdapterFetchInput): Promise<PerformanceSnapshot>;
}

export interface CalibrationContext {
  scoringRun: ScoringRun;
  qaReport: QaReport;
  snapshot: PerformanceSnapshot;
}
  • Step 4: Add validation schemas

Create src/lib/calibration/validation.ts:

import { z } from "zod";

import { publishPlatformSchema } from "../domain/validation";
import type {
  CalibrationDirection,
  CalibrationEvent,
  PerformanceMetrics,
  PerformanceSnapshot,
  PublicationRecord,
  RubricVersion,
  ScoringRun,
} from "./types";

const optionalTextSchema = z
  .preprocess((value) => (value == null ? "" : value), z.string())
  .transform((value) => value.trim());

function optionalMetric(value: unknown) {
  if (value == null || value === "") return undefined;
  const numberValue = typeof value === "number" ? value : Number(value);
  return Number.isFinite(numberValue) && numberValue >= 0
    ? numberValue
    : value;
}

const metricsShape = {
  views: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  impressions: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  clicks: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  inquiries: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  likes: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  comments: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  shares: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  saves: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
  average_position: z.preprocess(optionalMetric, z.number().nonnegative().optional()),
};

export const performanceMetricsSchema = z
  .object(metricsShape)
  .transform((metrics) =>
    Object.fromEntries(
      Object.entries(metrics).filter(([, value]) => value !== undefined),
    ) as PerformanceMetrics,
  );

export const publicationInputSchema = z.object({
  platform: publishPlatformSchema,
  url: z.string().trim().url(),
  published_at: z.string().datetime(),
  notes: optionalTextSchema.default(""),
});

export const manualPerformanceInputSchema = z
  .object({
    window_label: z.string().trim().min(1),
    feedback_summary: optionalTextSchema.default(""),
    raw_reference: optionalTextSchema.optional(),
    ...metricsShape,
  })
  .transform(({ window_label, feedback_summary, raw_reference, ...metrics }) => ({
    window_label,
    feedback_summary,
    raw_reference,
    metrics: performanceMetricsSchema.parse(metrics),
  }));

function rejectUnsafeRawReference(value: string | undefined) {
  if (!value) return value;
  if (/cookie|sessionid|token|secret|api[_-]?key|authorization/i.test(value)) {
    throw new Error("raw_reference must not contain credentials");
  }
  return value;
}

export const rubricVersionSchema = z.object({
  id: z.string().trim().min(1),
  version: z.string().trim().min(1),
  name: z.string().trim().min(1),
  dimensions: z.array(
    z.object({
      id: z.string().trim().min(1),
      label: z.string().trim().min(1),
      weight: z.number().positive(),
      description: z.string().trim().min(1),
    }),
  ),
  formula: z.literal("weighted_average_0_to_10"),
  is_active: z.boolean(),
  created_at: z.string().datetime(),
}) satisfies z.ZodType<RubricVersion>;

export const scoringRunSchema = z.object({
  id: z.string().trim().min(1),
  job_id: z.string().trim().min(1),
  revision: z.number().int().positive(),
  rubric_version_id: z.string().trim().min(1),
  dimension_scores: z.record(z.string(), z.number().min(0).max(5)),
  composite_score: z.number().min(0).max(10),
  rationale: z.string().trim(),
  created_at: z.string().datetime(),
}) satisfies z.ZodType<ScoringRun>;

export const publicationRecordSchema = z.object({
  id: z.string().trim().min(1),
  job_id: z.string().trim().min(1),
  revision: z.number().int().positive(),
  platform: publishPlatformSchema,
  url: z.string().trim().url(),
  published_at: z.string().datetime(),
  status: z.enum(["draft", "published", "archived"]),
  notes: z.string().trim(),
  created_at: z.string().datetime(),
  updated_at: z.string().datetime(),
}) satisfies z.ZodType<PublicationRecord>;

export const performanceSnapshotSchema = z
  .object({
    id: z.string().trim().min(1),
    publication_id: z.string().trim().min(1),
    source: z.union([z.literal("manual"), z.templateLiteral(["adapter:", z.string()])]),
    window_label: z.string().trim().min(1),
    metrics: performanceMetricsSchema,
    feedback_summary: z.string().trim(),
    raw_reference: z.string().trim().optional(),
    snapshot_at: z.string().datetime(),
  })
  .transform((snapshot) => ({
    ...snapshot,
    raw_reference: rejectUnsafeRawReference(snapshot.raw_reference),
  })) satisfies z.ZodType<PerformanceSnapshot>;

export const calibrationDirectionSchema = z.enum([
  "better_than_expected",
  "as_expected",
  "worse_than_expected",
  "needs_more_data",
]) satisfies z.ZodType<CalibrationDirection>;

export const calibrationEventSchema = z.object({
  id: z.string().trim().min(1),
  publication_id: z.string().trim().min(1),
  scoring_run_id: z.string().trim().min(1),
  performance_snapshot_id: z.string().trim().min(1),
  direction: calibrationDirectionSchema,
  observations: z.array(z.string().trim().min(1)),
  recommended_action: z.string().trim().min(1),
  created_at: z.string().datetime(),
}) satisfies z.ZodType<CalibrationEvent>;
  • Step 5: Run validation tests

Run:

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

Expected: PASS.

  • Step 6: Commit Task 1
git add src/lib/calibration/types.ts src/lib/calibration/validation.ts src/lib/calibration/__tests__/validation.test.ts
git commit -m "新增发布校准领域类型"

Task 2: Deterministic GEO Scoring And Manual Adapter

Files:

  • Create: src/lib/calibration/scoring.ts

  • Create: src/lib/calibration/manual-adapter.ts

  • Create: src/lib/calibration/__tests__/scoring.test.ts

  • Step 1: Write failing scoring tests

Create src/lib/calibration/__tests__/scoring.test.ts:

import { describe, expect, it } from "vitest";

import type { OptimizedArticle, QaReport } from "../../domain/types";
import { createManualPerformanceAdapter } from "../manual-adapter";
import {
  GEO_RUBRIC_V1,
  createCalibrationEvent,
  scoreOptimizedArticle,
} from "../scoring";

const article: OptimizedArticle = {
  job_id: "job_123",
  revision: 2,
  title: "示例科技 GEO 内容优化方案",
  summary: "示例科技有限公司面向市场团队提供GEO内容优化服务。",
  body_markdown:
    "## 服务能力\n示例科技有限公司提供GEO内容优化服务,帮助市场团队提升AI搜索可见性。\n## 可信依据\n文章保留事实卡中的8年经验描述。",
  image_suggestions: [],
  changed_sections: ["title", "body"],
  requires_user_confirmation: [],
};

const qaReport: QaReport = {
  job_id: "job_123",
  revision: 2,
  overall_status: "warn",
  checks: [
    {
      rule_id: "hallucination_risk",
      status: "warn",
      evidence: "8年经验",
      reason: "需要人工复核经验年限依据。",
      suggested_fix: "确认事实卡。",
      target_agent: "body",
    },
  ],
};

describe("calibration scoring", () => {
  it("scores an optimized article with the active GEO rubric", () => {
    const run = scoreOptimizedArticle({
      jobId: "job_123",
      article,
      qaReport,
    });

    expect(run.rubric_version_id).toBe(GEO_RUBRIC_V1.id);
    expect(run.dimension_scores.fact_integrity).toBe(4);
    expect(run.dimension_scores.readability).toBeGreaterThanOrEqual(4);
    expect(run.composite_score).toBeGreaterThan(6);
    expect(run.composite_score).toBeLessThanOrEqual(10);
  });

  it("normalizes manual performance input through the adapter boundary", async () => {
    const adapter = createManualPerformanceAdapter();
    const snapshot = await adapter.fetch({
      publication: {
        id: "pub_123",
        job_id: "job_123",
        revision: 2,
        platform: "official_site",
        url: "https://example.com/article",
        published_at: "2026-06-24T12:00:00.000Z",
        status: "published",
        notes: "",
        created_at: "2026-06-24T12:00:00.000Z",
        updated_at: "2026-06-24T12:00:00.000Z",
      },
      window_label: "T+7d",
      manualInput: {
        window_label: "T+7d",
        views: "1200",
        inquiries: "7",
        feedback_summary: "用户追问案例依据",
      },
    });

    expect(snapshot.source).toBe("manual");
    expect(snapshot.metrics).toEqual({ views: 1200, inquiries: 7 });
  });

  it("creates a reviewable calibration event without changing the article", () => {
    const scoringRun = scoreOptimizedArticle({
      jobId: "job_123",
      article,
      qaReport,
    });
    const event = createCalibrationEvent({
      scoringRun,
      qaReport,
      snapshot: {
        id: "perf_123",
        publication_id: "pub_123",
        source: "manual",
        window_label: "T+7d",
        metrics: { views: 1200, inquiries: 7 },
        feedback_summary: "用户追问案例依据",
        snapshot_at: "2026-07-01T12:00:00.000Z",
      },
    });

    expect(event.direction).toBe("better_than_expected");
    expect(event.observations.join(" ")).toContain("询盘");
    expect(event.recommended_action).toContain("积累");
  });
});
  • Step 2: Run failing scoring tests

Run:

npm test -- src/lib/calibration/__tests__/scoring.test.ts

Expected: FAIL because scoring/manual adapter files do not exist.

  • Step 3: Implement scoring service

Create src/lib/calibration/scoring.ts:

import { nanoid } from "nanoid";

import type { OptimizedArticle, QaReport } from "../domain/types";
import type {
  CalibrationContext,
  CalibrationDirection,
  CalibrationEvent,
  RubricVersion,
  ScoringRun,
} from "./types";

export const GEO_RUBRIC_V1: RubricVersion = {
  id: "rubric_geo_v1",
  version: "v1",
  name: "GEO article performance rubric",
  formula: "weighted_average_0_to_10",
  is_active: true,
  created_at: "2026-06-24T00:00:00.000Z",
  dimensions: [
    {
      id: "fact_integrity",
      label: "事实一致性",
      weight: 2,
      description: "事实、公司名、产品名和经验年限是否遵守事实卡。",
    },
    {
      id: "platform_fit",
      label: "平台适配",
      weight: 1.5,
      description: "表达是否匹配目标发布平台。",
    },
    {
      id: "search_intent_fit",
      label: "搜索意图匹配",
      weight: 1.5,
      description: "是否回答GEO/Search背后的用户问题。",
    },
    {
      id: "answer_density",
      label: "答案密度",
      weight: 1.5,
      description: "是否提供具体信息而不是泛泛宣传。",
    },
    {
      id: "trust_signal_quality",
      label: "信任信号质量",
      weight: 1.5,
      description: "可信依据是否具体、克制且可复核。",
    },
    {
      id: "readability",
      label: "可读性",
      weight: 1,
      description: "标题、摘要、正文是否清晰易读。",
    },
  ],
};

interface ScoreOptimizedArticleInput {
  jobId: string;
  article: OptimizedArticle;
  qaReport: QaReport;
}

export function scoreOptimizedArticle({
  jobId,
  article,
  qaReport,
}: ScoreOptimizedArticleInput): ScoringRun {
  const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
  const dimensionScores = {
    fact_integrity: scoreFactIntegrity(qaReport),
    platform_fit: scoreRuleGroup(qaReport, ["platform_fit", "voice_consistency"]),
    search_intent_fit: hasGeoIntent(combined) ? 4 : 2,
    answer_density: scoreAnswerDensity(combined),
    trust_signal_quality: scoreTrustSignals(combined, qaReport),
    readability: scoreReadability(article),
  };
  const composite = weightedComposite(dimensionScores);

  return {
    id: `score_${nanoid(10)}`,
    job_id: jobId,
    revision: article.revision ?? 1,
    rubric_version_id: GEO_RUBRIC_V1.id,
    dimension_scores: dimensionScores,
    composite_score: composite,
    rationale: buildRationale(dimensionScores),
    created_at: new Date().toISOString(),
  };
}

function scoreFactIntegrity(report: QaReport) {
  const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"];
  const statuses = report.checks
    .filter((check) => hardRules.includes(check.rule_id))
    .map((check) => check.status);
  if (statuses.includes("fail")) return 1;
  if (statuses.includes("warn")) return 4;
  return 5;
}

function scoreRuleGroup(report: QaReport, ruleIds: string[]) {
  const statuses = report.checks
    .filter((check) => ruleIds.includes(check.rule_id))
    .map((check) => check.status);
  if (statuses.includes("fail")) return 2;
  if (statuses.includes("warn")) return 3;
  return 4;
}

function hasGeoIntent(text: string) {
  return /GEO|AI搜索|生成式引擎|搜索|可见性|问答|推荐/i.test(text);
}

function scoreAnswerDensity(text: string) {
  const headings = (text.match(/^##\s+/gm) ?? []).length;
  const concreteSignals = (text.match(/服务|流程|方案|能力|团队|行业|客户|案例/g) ?? [])
    .length;
  if (headings >= 2 && concreteSignals >= 8) return 5;
  if (headings >= 1 && concreteSignals >= 4) return 4;
  if (concreteSignals >= 2) return 3;
  return 2;
}

function scoreTrustSignals(text: string, report: QaReport) {
  const hallucination = report.checks.find(
    (check) => check.rule_id === "hallucination_risk",
  );
  if (hallucination?.status === "fail") return 1;
  const trustSignals = (text.match(/依据|经验|资质|案例|事实卡|复核|客户/g) ?? [])
    .length;
  if (hallucination?.status === "warn") return trustSignals >= 2 ? 3 : 2;
  return trustSignals >= 2 ? 4 : 3;
}

function scoreReadability(article: OptimizedArticle) {
  const longSentence = `${article.summary}\n${article.body_markdown}`
    .split(/[。!?.!?]/)
    .some((sentence) => sentence.length > 180);
  if (article.title.length > 42 || longSentence) return 3;
  return 4;
}

function weightedComposite(scores: Record<string, number>) {
  const totalWeight = GEO_RUBRIC_V1.dimensions.reduce(
    (sum, dimension) => sum + dimension.weight,
    0,
  );
  const weighted = GEO_RUBRIC_V1.dimensions.reduce(
    (sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight,
    0,
  );
  return Math.round((weighted / totalWeight) * 2 * 10) / 10;
}

function buildRationale(scores: Record<string, number>) {
  return `事实一致性 ${scores.fact_integrity}/5,平台适配 ${scores.platform_fit}/5,答案密度 ${scores.answer_density}/5,信任信号 ${scores.trust_signal_quality}/5。`;
}

export function createCalibrationEvent({
  scoringRun,
  qaReport,
  snapshot,
}: CalibrationContext): CalibrationEvent {
  const direction = inferDirection(scoringRun.composite_score, snapshot.metrics);
  const observations = buildObservations(direction, scoringRun, qaReport, snapshot.feedback_summary);

  return {
    id: `cal_${nanoid(10)}`,
    publication_id: snapshot.publication_id,
    scoring_run_id: scoringRun.id,
    performance_snapshot_id: snapshot.id,
    direction,
    observations,
    recommended_action:
      "先积累至少 5 篇同类样本,再评估是否调整 GEO rubric 权重。",
    created_at: new Date().toISOString(),
  };
}

function inferDirection(
  composite: number,
  metrics: { views?: number; clicks?: number; inquiries?: number },
): CalibrationDirection {
  if (!metrics.views && !metrics.clicks && !metrics.inquiries) {
    return "needs_more_data";
  }
  if ((metrics.inquiries ?? 0) >= 3 || (metrics.clicks ?? 0) >= 50) {
    return composite >= 6 ? "better_than_expected" : "better_than_expected";
  }
  if ((metrics.views ?? 0) < 100 && composite >= 7) {
    return "worse_than_expected";
  }
  return "as_expected";
}

function buildObservations(
  direction: CalibrationDirection,
  scoringRun: ScoringRun,
  qaReport: QaReport,
  feedbackSummary: string,
) {
  const observations = [
    `综合评分 ${scoringRun.composite_score}/10,真实表现方向为 ${direction}。`,
  ];
  if (qaReport.overall_status !== "pass") {
    observations.push(`QA 状态为 ${qaReport.overall_status},需要和表现数据一起复盘。`);
  }
  if (feedbackSummary) {
    observations.push(`反馈摘要:${feedbackSummary}`);
  }
  if ((scoringRun.dimension_scores.trust_signal_quality ?? 0) <= 3) {
    observations.push("信任信号质量偏低,后续观察是否影响询盘。");
  }
  return observations;
}
  • Step 4: Implement manual adapter

Create src/lib/calibration/manual-adapter.ts:

import { nanoid } from "nanoid";

import type {
  AdapterFetchInput,
  PerformanceAdapter,
  PerformanceSnapshot,
} from "./types";
import { manualPerformanceInputSchema, performanceSnapshotSchema } from "./validation";

interface ManualAdapterFetchInput extends AdapterFetchInput {
  manualInput: unknown;
}

export function createManualPerformanceAdapter(): PerformanceAdapter & {
  fetch(input: ManualAdapterFetchInput): Promise<PerformanceSnapshot>;
} {
  return {
    source: "manual",
    async fetch(input) {
      const parsed = manualPerformanceInputSchema.parse(input.manualInput);
      return performanceSnapshotSchema.parse({
        id: `perf_${nanoid(10)}`,
        publication_id: input.publication.id,
        source: "manual",
        window_label: parsed.window_label,
        metrics: parsed.metrics,
        feedback_summary: parsed.feedback_summary,
        raw_reference: parsed.raw_reference,
        snapshot_at: new Date().toISOString(),
      });
    },
  };
}
  • Step 5: Run scoring tests

Run:

npm test -- src/lib/calibration/__tests__/scoring.test.ts

Expected: PASS.

  • Step 6: Commit Task 2
git add src/lib/calibration/scoring.ts src/lib/calibration/manual-adapter.ts src/lib/calibration/__tests__/scoring.test.ts
git commit -m "新增发布表现评分服务"

Task 3: SQLite And D1 Schema

Files:

  • Modify: src/lib/db/schema.ts

  • Create: migrations/0002_publication_performance_calibration.sql

  • Step 1: Add local schema tables

In src/lib/db/schema.ts, append these tables inside the existing db.exec(\...`)block afterqa_reports`:

    create table if not exists rubric_versions (
      id text primary key,
      version text not null,
      name text not null,
      dimensions text not null,
      formula text not null,
      is_active integer not null,
      created_at text not null
    );

    create table if not exists scoring_runs (
      id text primary key,
      job_id text not null,
      revision integer not null,
      rubric_version_id text not null,
      dimension_scores text not null,
      composite_score real not null,
      rationale text not null,
      created_at text not null,
      foreign key (job_id, revision)
        references optimized_articles(job_id, revision) on delete cascade,
      foreign key (rubric_version_id) references rubric_versions(id)
    );

    create table if not exists publication_records (
      id text primary key,
      job_id text not null,
      revision integer not null,
      platform text not null,
      url text not null,
      published_at text not null,
      status text not null,
      notes text not null,
      created_at text not null,
      updated_at text not null,
      foreign key (job_id, revision)
        references optimized_articles(job_id, revision) on delete cascade
    );

    create table if not exists performance_snapshots (
      id text primary key,
      publication_id text not null,
      source text not null,
      window_label text not null,
      metrics text not null,
      feedback_summary text not null,
      raw_reference text,
      snapshot_at text not null,
      foreign key (publication_id) references publication_records(id) on delete cascade
    );

    create table if not exists calibration_events (
      id text primary key,
      publication_id text not null,
      scoring_run_id text not null,
      performance_snapshot_id text not null,
      direction text not null,
      observations text not null,
      recommended_action text not null,
      created_at text not null,
      foreign key (publication_id) references publication_records(id) on delete cascade,
      foreign key (scoring_run_id) references scoring_runs(id) on delete cascade,
      foreign key (performance_snapshot_id)
        references performance_snapshots(id) on delete cascade
    );

    create index if not exists idx_scoring_runs_job_revision
      on scoring_runs(job_id, revision);

    create index if not exists idx_publication_records_job_revision
      on publication_records(job_id, revision);

    create index if not exists idx_performance_snapshots_publication
      on performance_snapshots(publication_id);
  • Step 2: Add D1 migration

Create migrations/0002_publication_performance_calibration.sql with the same SQL in Cloudflare-compatible uppercase style:

CREATE TABLE IF NOT EXISTS rubric_versions (
  id TEXT PRIMARY KEY,
  version TEXT NOT NULL,
  name TEXT NOT NULL,
  dimensions TEXT NOT NULL,
  formula TEXT NOT NULL,
  is_active INTEGER NOT NULL,
  created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS scoring_runs (
  id TEXT PRIMARY KEY,
  job_id TEXT NOT NULL,
  revision INTEGER NOT NULL,
  rubric_version_id TEXT NOT NULL,
  dimension_scores TEXT NOT NULL,
  composite_score REAL NOT NULL,
  rationale TEXT NOT NULL,
  created_at TEXT NOT NULL,
  FOREIGN KEY (job_id, revision)
    REFERENCES optimized_articles(job_id, revision) ON DELETE CASCADE,
  FOREIGN KEY (rubric_version_id) REFERENCES rubric_versions(id)
);

CREATE TABLE IF NOT EXISTS publication_records (
  id TEXT PRIMARY KEY,
  job_id TEXT NOT NULL,
  revision INTEGER NOT NULL,
  platform TEXT NOT NULL,
  url TEXT NOT NULL,
  published_at TEXT NOT NULL,
  status TEXT NOT NULL,
  notes TEXT NOT NULL,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  FOREIGN KEY (job_id, revision)
    REFERENCES optimized_articles(job_id, revision) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS performance_snapshots (
  id TEXT PRIMARY KEY,
  publication_id TEXT NOT NULL,
  source TEXT NOT NULL,
  window_label TEXT NOT NULL,
  metrics TEXT NOT NULL,
  feedback_summary TEXT NOT NULL,
  raw_reference TEXT,
  snapshot_at TEXT NOT NULL,
  FOREIGN KEY (publication_id) REFERENCES publication_records(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS calibration_events (
  id TEXT PRIMARY KEY,
  publication_id TEXT NOT NULL,
  scoring_run_id TEXT NOT NULL,
  performance_snapshot_id TEXT NOT NULL,
  direction TEXT NOT NULL,
  observations TEXT NOT NULL,
  recommended_action TEXT NOT NULL,
  created_at TEXT NOT NULL,
  FOREIGN KEY (publication_id) REFERENCES publication_records(id) ON DELETE CASCADE,
  FOREIGN KEY (scoring_run_id) REFERENCES scoring_runs(id) ON DELETE CASCADE,
  FOREIGN KEY (performance_snapshot_id)
    REFERENCES performance_snapshots(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_scoring_runs_job_revision
  ON scoring_runs(job_id, revision);

CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision
  ON publication_records(job_id, revision);

CREATE INDEX IF NOT EXISTS idx_performance_snapshots_publication
  ON performance_snapshots(publication_id);
  • Step 3: Run schema syntax check

Run:

node -e "const fs=require('fs'); const sql=fs.readFileSync('migrations/0002_publication_performance_calibration.sql','utf8'); console.log(sql.includes('performance_snapshots') ? 'migration ok' : 'missing table')"

Expected: prints migration ok.

  • Step 4: Commit Task 3
git add src/lib/db/schema.ts migrations/0002_publication_performance_calibration.sql
git commit -m "新增发布校准数据库结构"

Task 4: Repository Methods For Calibration

Files:

  • Modify: src/lib/db/repositories.ts

  • Modify: src/lib/db/repository.ts

  • Modify: src/lib/db/sqlite-repository.ts

  • Modify: src/lib/db/d1-repository.ts

  • Modify: src/lib/db/__tests__/repository.test.ts

  • Modify: src/lib/db/__tests__/d1-repository.test.ts

  • Step 1: Write failing SQLite repository test

Append to src/lib/db/__tests__/repository.test.ts:

  test("persists scoring, publication, performance, and calibration event", async () => {
    const repository = createSqliteRepository(dbPath);
    const job = await repository.createArticleJob({
      source_title: "Title",
      source_body: "Body",
      image_inputs: [],
      publish_platform: "official_site",
      user_instructions: "",
    });
    const article = await repository.saveOptimizedArticle(job.id, {
      title: "Optimized",
      summary: "Summary",
      body_markdown: "Body",
      image_suggestions: [],
      changed_sections: [],
      requires_user_confirmation: [],
    });

    await repository.saveRubricVersion({
      id: "rubric_geo_v1",
      version: "v1",
      name: "GEO rubric",
      dimensions: [],
      formula: "weighted_average_0_to_10",
      is_active: true,
      created_at: "2026-06-24T00:00:00.000Z",
    });
    const scoringRun = await repository.saveScoringRun({
      id: "score_1",
      job_id: job.id,
      revision: article.revision ?? 1,
      rubric_version_id: "rubric_geo_v1",
      dimension_scores: { readability: 4 },
      composite_score: 8,
      rationale: "Readable",
      created_at: "2026-06-24T00:00:00.000Z",
    });
    const publication = await repository.createPublicationRecord({
      job_id: job.id,
      revision: article.revision ?? 1,
      platform: "official_site",
      url: "https://example.com/article",
      published_at: "2026-06-24T12:00:00.000Z",
      status: "published",
      notes: "官网首发",
    });
    const snapshot = await repository.savePerformanceSnapshot({
      id: "perf_1",
      publication_id: publication.id,
      source: "manual",
      window_label: "T+7d",
      metrics: { views: 1200 },
      feedback_summary: "用户追问案例依据",
      snapshot_at: "2026-07-01T12:00:00.000Z",
    });
    const event = await repository.saveCalibrationEvent({
      id: "cal_1",
      publication_id: publication.id,
      scoring_run_id: scoringRun.id,
      performance_snapshot_id: snapshot.id,
      direction: "better_than_expected",
      observations: ["表现高于预期"],
      recommended_action: "继续积累样本",
      created_at: "2026-07-01T12:10:00.000Z",
    });

    await expect(repository.listPublicationRecords(job.id)).resolves.toHaveLength(1);
    await expect(repository.getLatestScoringRun(job.id, article.revision ?? 1))
      .resolves.toMatchObject({ id: scoringRun.id, composite_score: 8 });
    await expect(repository.listPerformanceSnapshots(publication.id))
      .resolves.toEqual([expect.objectContaining({ id: snapshot.id })]);
    expect(event.observations).toEqual(["表现高于预期"]);
  });
  • Step 2: Run failing repository test

Run:

npm test -- src/lib/db/__tests__/repository.test.ts -t "persists scoring"

Expected: FAIL because repository methods do not exist.

  • Step 3: Extend repository interface

In src/lib/db/repository.ts, import calibration types and add methods to AppRepository:

import type {
  CalibrationEvent,
  PerformanceSnapshot,
  PublicationRecord,
  RubricVersion,
  ScoringRun,
} from "../calibration/types";

Add these interface methods:

  saveRubricVersion(rubric: RubricVersion): Promise<RubricVersion>;
  saveScoringRun(run: ScoringRun): Promise<ScoringRun>;
  getLatestScoringRun(jobId: string, revision: number): Promise<ScoringRun | null>;
  createPublicationRecord(
    input: Omit<PublicationRecord, "id" | "created_at" | "updated_at">,
  ): Promise<PublicationRecord>;
  listPublicationRecords(jobId: string): Promise<PublicationRecord[]>;
  getPublicationRecord(id: string): Promise<PublicationRecord | null>;
  savePerformanceSnapshot(snapshot: PerformanceSnapshot): Promise<PerformanceSnapshot>;
  listPerformanceSnapshots(publicationId: string): Promise<PerformanceSnapshot[]>;
  saveCalibrationEvent(event: CalibrationEvent): Promise<CalibrationEvent>;
  • Step 4: Add SQLite helper types and functions

In src/lib/db/repositories.ts, import calibration types and add row interfaces:

import type {
  CalibrationEvent,
  PerformanceSnapshot,
  PublicationRecord,
  RubricVersion,
  ScoringRun,
} from "../calibration/types";

Add row interfaces near existing row interfaces:

interface RubricVersionRow {
  id: string;
  version: string;
  name: string;
  dimensions: string;
  formula: "weighted_average_0_to_10";
  is_active: number;
  created_at: string;
}

interface ScoringRunRow {
  id: string;
  job_id: string;
  revision: number;
  rubric_version_id: string;
  dimension_scores: string;
  composite_score: number;
  rationale: string;
  created_at: string;
}

interface PublicationRecordRow {
  id: string;
  job_id: string;
  revision: number;
  platform: PublishPlatform;
  url: string;
  published_at: string;
  status: "draft" | "published" | "archived";
  notes: string;
  created_at: string;
  updated_at: string;
}

interface PerformanceSnapshotRow {
  id: string;
  publication_id: string;
  source: "manual" | `adapter:${string}`;
  window_label: string;
  metrics: string;
  feedback_summary: string;
  raw_reference: string | null;
  snapshot_at: string;
}

interface CalibrationEventRow {
  id: string;
  publication_id: string;
  scoring_run_id: string;
  performance_snapshot_id: string;
  direction: CalibrationEvent["direction"];
  observations: string;
  recommended_action: string;
  created_at: string;
}

Add mapper helpers:

function toRubricVersion(row: RubricVersionRow): RubricVersion {
  return {
    ...row,
    dimensions: parseJson<RubricVersion["dimensions"]>(row.dimensions),
    is_active: Boolean(row.is_active),
  };
}

function toScoringRun(row: ScoringRunRow): ScoringRun {
  return {
    ...row,
    dimension_scores: parseJson<Record<string, number>>(row.dimension_scores),
  };
}

function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot {
  return {
    ...row,
    raw_reference: row.raw_reference ?? undefined,
    metrics: parseJson<PerformanceSnapshot["metrics"]>(row.metrics),
  };
}

function toCalibrationEvent(row: CalibrationEventRow): CalibrationEvent {
  return {
    ...row,
    observations: parseJson<string[]>(row.observations),
  };
}

Add exported functions after getLatestQaReport:

export function saveRubricVersion(dbPath: string | undefined, rubric: RubricVersion) {
  return withDb(dbPath, (db) => {
    db.prepare(
      `insert into rubric_versions (
        id, version, name, dimensions, formula, is_active, created_at
      ) values (?, ?, ?, ?, ?, ?, ?)
      on conflict(id) do update set
        version = excluded.version,
        name = excluded.name,
        dimensions = excluded.dimensions,
        formula = excluded.formula,
        is_active = excluded.is_active`,
    ).run(
      rubric.id,
      rubric.version,
      rubric.name,
      serialize(rubric.dimensions),
      rubric.formula,
      rubric.is_active ? 1 : 0,
      rubric.created_at,
    );
    return rubric;
  });
}

export function saveScoringRun(dbPath: string | undefined, run: ScoringRun) {
  return withDb(dbPath, (db) => {
    db.prepare(
      `insert into scoring_runs (
        id, job_id, revision, rubric_version_id, dimension_scores,
        composite_score, rationale, created_at
      ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run(
      run.id,
      run.job_id,
      run.revision,
      run.rubric_version_id,
      serialize(run.dimension_scores),
      run.composite_score,
      run.rationale,
      run.created_at,
    );
    return run;
  });
}

export function getLatestScoringRun(
  dbPath: string | undefined,
  jobId: string,
  revision: number,
) {
  return withDb(dbPath, (db) => {
    const row = db
      .prepare(
        `select * from scoring_runs
         where job_id = ? and revision = ?
         order by created_at desc
         limit 1`,
      )
      .get(jobId, revision) as ScoringRunRow | undefined;
    return row ? toScoringRun(row) : null;
  });
}

export function createPublicationRecord(
  dbPath: string | undefined,
  input: Omit<PublicationRecord, "id" | "created_at" | "updated_at">,
) {
  return withDb(dbPath, (db) => {
    const timestamp = nowIso();
    const record: PublicationRecord = {
      id: `pub_${nanoid(10)}`,
      ...input,
      created_at: timestamp,
      updated_at: timestamp,
    };
    db.prepare(
      `insert into publication_records (
        id, job_id, revision, platform, url, published_at, status,
        notes, created_at, updated_at
      ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run(
      record.id,
      record.job_id,
      record.revision,
      record.platform,
      record.url,
      record.published_at,
      record.status,
      record.notes,
      record.created_at,
      record.updated_at,
    );
    return record;
  });
}

export function listPublicationRecords(dbPath: string | undefined, jobId: string) {
  return withDb(dbPath, (db) =>
    db
      .prepare("select * from publication_records where job_id = ? order by published_at desc")
      .all(jobId)
      .map((row) => row as PublicationRecord),
  );
}

export function getPublicationRecord(dbPath: string | undefined, id: string) {
  return withDb(dbPath, (db) => {
    const row = db
      .prepare("select * from publication_records where id = ?")
      .get(id) as PublicationRecordRow | undefined;
    return row ?? null;
  });
}

export function savePerformanceSnapshot(
  dbPath: string | undefined,
  snapshot: PerformanceSnapshot,
) {
  return withDb(dbPath, (db) => {
    db.prepare(
      `insert into performance_snapshots (
        id, publication_id, source, window_label, metrics,
        feedback_summary, raw_reference, snapshot_at
      ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run(
      snapshot.id,
      snapshot.publication_id,
      snapshot.source,
      snapshot.window_label,
      serialize(snapshot.metrics),
      snapshot.feedback_summary,
      snapshot.raw_reference ?? null,
      snapshot.snapshot_at,
    );
    return snapshot;
  });
}

export function listPerformanceSnapshots(
  dbPath: string | undefined,
  publicationId: string,
) {
  return withDb(dbPath, (db) =>
    db
      .prepare(
        "select * from performance_snapshots where publication_id = ? order by snapshot_at desc",
      )
      .all(publicationId)
      .map((row) => toPerformanceSnapshot(row as PerformanceSnapshotRow)),
  );
}

export function saveCalibrationEvent(
  dbPath: string | undefined,
  event: CalibrationEvent,
) {
  return withDb(dbPath, (db) => {
    db.prepare(
      `insert into calibration_events (
        id, publication_id, scoring_run_id, performance_snapshot_id,
        direction, observations, recommended_action, created_at
      ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run(
      event.id,
      event.publication_id,
      event.scoring_run_id,
      event.performance_snapshot_id,
      event.direction,
      serialize(event.observations),
      event.recommended_action,
      event.created_at,
    );
    return event;
  });
}
  • Step 5: Expose SQLite methods

In src/lib/db/sqlite-repository.ts, add the imported helper names and methods:

  createPublicationRecord,
  getLatestScoringRun,
  getPublicationRecord,
  listPerformanceSnapshots,
  listPublicationRecords,
  saveCalibrationEvent,
  savePerformanceSnapshot,
  saveRubricVersion,
  saveScoringRun,

Add implementations in createSqliteRepository:

    saveRubricVersion(rubric) {
      return Promise.resolve(saveRubricVersion(dbPath, rubric));
    },
    saveScoringRun(run) {
      return Promise.resolve(saveScoringRun(dbPath, run));
    },
    getLatestScoringRun(jobId, revision) {
      return Promise.resolve(getLatestScoringRun(dbPath, jobId, revision));
    },
    createPublicationRecord(input) {
      return Promise.resolve(createPublicationRecord(dbPath, input));
    },
    listPublicationRecords(jobId) {
      return Promise.resolve(listPublicationRecords(dbPath, jobId));
    },
    getPublicationRecord(id) {
      return Promise.resolve(getPublicationRecord(dbPath, id));
    },
    savePerformanceSnapshot(snapshot) {
      return Promise.resolve(savePerformanceSnapshot(dbPath, snapshot));
    },
    listPerformanceSnapshots(publicationId) {
      return Promise.resolve(listPerformanceSnapshots(dbPath, publicationId));
    },
    saveCalibrationEvent(event) {
      return Promise.resolve(saveCalibrationEvent(dbPath, event));
    },
  • Step 6: Add D1 methods

In src/lib/db/d1-repository.ts, import calibration types:

import type {
  CalibrationEvent,
  PerformanceSnapshot,
  PublicationRecord,
  RubricVersion,
  ScoringRun,
} from "../calibration/types";

Add row interfaces near the existing D1 row interfaces:

interface RubricVersionRow {
  id: string;
  version: string;
  name: string;
  dimensions: string;
  formula: "weighted_average_0_to_10";
  is_active: number;
  created_at: string;
}

interface ScoringRunRow {
  id: string;
  job_id: string;
  revision: number;
  rubric_version_id: string;
  dimension_scores: string;
  composite_score: number;
  rationale: string;
  created_at: string;
}

interface PublicationRecordRow {
  id: string;
  job_id: string;
  revision: number;
  platform: PublishPlatform;
  url: string;
  published_at: string;
  status: "draft" | "published" | "archived";
  notes: string;
  created_at: string;
  updated_at: string;
}

interface PerformanceSnapshotRow {
  id: string;
  publication_id: string;
  source: "manual" | `adapter:${string}`;
  window_label: string;
  metrics: string;
  feedback_summary: string;
  raw_reference: string | null;
  snapshot_at: string;
}

interface CalibrationEventRow {
  id: string;
  publication_id: string;
  scoring_run_id: string;
  performance_snapshot_id: string;
  direction: CalibrationEvent["direction"];
  observations: string;
  recommended_action: string;
  created_at: string;
}

Add mapper helpers below toArticleJob:

function toRubricVersion(row: RubricVersionRow): RubricVersion {
  return {
    ...row,
    dimensions: parseJson<RubricVersion["dimensions"]>(row.dimensions),
    is_active: Boolean(row.is_active),
  };
}

function toScoringRun(row: ScoringRunRow): ScoringRun {
  return {
    ...row,
    dimension_scores: parseJson<Record<string, number>>(row.dimension_scores),
  };
}

function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot {
  return {
    ...row,
    raw_reference: row.raw_reference ?? undefined,
    metrics: parseJson<PerformanceSnapshot["metrics"]>(row.metrics),
  };
}

function toCalibrationEvent(row: CalibrationEventRow): CalibrationEvent {
  return {
    ...row,
    observations: parseJson<string[]>(row.observations),
  };
}

Add these methods inside the object returned by createD1Repository after getLatestQaReport:

    async saveRubricVersion(rubric) {
      await db
        .prepare(
          `insert into rubric_versions (
            id, version, name, dimensions, formula, is_active, created_at
          ) values (?, ?, ?, ?, ?, ?, ?)
          on conflict(id) do update set
            version = excluded.version,
            name = excluded.name,
            dimensions = excluded.dimensions,
            formula = excluded.formula,
            is_active = excluded.is_active`,
        )
        .bind(
          rubric.id,
          rubric.version,
          rubric.name,
          serialize(rubric.dimensions),
          rubric.formula,
          rubric.is_active ? 1 : 0,
          rubric.created_at,
        )
        .run();
      return rubric;
    },
    async saveScoringRun(run) {
      await db
        .prepare(
          `insert into scoring_runs (
            id, job_id, revision, rubric_version_id, dimension_scores,
            composite_score, rationale, created_at
          ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
        )
        .bind(
          run.id,
          run.job_id,
          run.revision,
          run.rubric_version_id,
          serialize(run.dimension_scores),
          run.composite_score,
          run.rationale,
          run.created_at,
        )
        .run();
      return run;
    },
    async getLatestScoringRun(jobId, revision) {
      const row = await db
        .prepare(
          `select * from scoring_runs
           where job_id = ? and revision = ?
           order by created_at desc
           limit 1`,
        )
        .bind(jobId, revision)
        .first<ScoringRunRow>();
      return row ? toScoringRun(row) : null;
    },
    async createPublicationRecord(input) {
      const timestamp = nowIso();
      const record: PublicationRecord = {
        id: `pub_${nanoid(10)}`,
        ...input,
        created_at: timestamp,
        updated_at: timestamp,
      };
      await db
        .prepare(
          `insert into publication_records (
            id, job_id, revision, platform, url, published_at, status,
            notes, created_at, updated_at
          ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        )
        .bind(
          record.id,
          record.job_id,
          record.revision,
          record.platform,
          record.url,
          record.published_at,
          record.status,
          record.notes,
          record.created_at,
          record.updated_at,
        )
        .run();
      return record;
    },
    async listPublicationRecords(jobId) {
      const result = await db
        .prepare(
          "select * from publication_records where job_id = ? order by published_at desc",
        )
        .bind(jobId)
        .all<PublicationRecordRow>();
      return result.results;
    },
    async getPublicationRecord(id) {
      return db
        .prepare("select * from publication_records where id = ?")
        .bind(id)
        .first<PublicationRecordRow>();
    },
    async savePerformanceSnapshot(snapshot) {
      await db
        .prepare(
          `insert into performance_snapshots (
            id, publication_id, source, window_label, metrics,
            feedback_summary, raw_reference, snapshot_at
          ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
        )
        .bind(
          snapshot.id,
          snapshot.publication_id,
          snapshot.source,
          snapshot.window_label,
          serialize(snapshot.metrics),
          snapshot.feedback_summary,
          snapshot.raw_reference ?? null,
          snapshot.snapshot_at,
        )
        .run();
      return snapshot;
    },
    async listPerformanceSnapshots(publicationId) {
      const result = await db
        .prepare(
          "select * from performance_snapshots where publication_id = ? order by snapshot_at desc",
        )
        .bind(publicationId)
        .all<PerformanceSnapshotRow>();
      return result.results.map(toPerformanceSnapshot);
    },
    async saveCalibrationEvent(event) {
      await db
        .prepare(
          `insert into calibration_events (
            id, publication_id, scoring_run_id, performance_snapshot_id,
            direction, observations, recommended_action, created_at
          ) values (?, ?, ?, ?, ?, ?, ?, ?)`,
        )
        .bind(
          event.id,
          event.publication_id,
          event.scoring_run_id,
          event.performance_snapshot_id,
          event.direction,
          serialize(event.observations),
          event.recommended_action,
          event.created_at,
        )
        .run();
      return event;
    },
  • Step 7: Add D1 representative test

Append to src/lib/db/__tests__/d1-repository.test.ts:

  test("saves a manual performance snapshot using D1 prepare and bind", async () => {
    const run = vi.fn().mockResolvedValue({ success: true });
    const bind = vi.fn().mockReturnValue({ run });
    const prepare = vi.fn().mockReturnValue({ bind });
    const db = { prepare } as unknown as D1Database;

    const repository = createD1Repository(db);

    await repository.savePerformanceSnapshot({
      id: "perf_123",
      publication_id: "pub_123",
      source: "manual",
      window_label: "T+7d",
      metrics: { views: 1200 },
      feedback_summary: "用户追问案例依据",
      snapshot_at: "2026-07-01T12:00:00.000Z",
    });

    expect(prepare).toHaveBeenCalledWith(
      expect.stringContaining("insert into performance_snapshots"),
    );
    expect(bind).toHaveBeenCalledWith(
      "perf_123",
      "pub_123",
      "manual",
      "T+7d",
      '{"views":1200}',
      "用户追问案例依据",
      null,
      "2026-07-01T12:00:00.000Z",
    );
  });
  • Step 8: Run repository tests

Run:

npm test -- src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/d1-repository.test.ts

Expected: PASS.

  • Step 9: Commit Task 4
git add src/lib/db/repositories.ts src/lib/db/repository.ts src/lib/db/sqlite-repository.ts src/lib/db/d1-repository.ts src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/d1-repository.test.ts
git commit -m "接入发布校准仓储接口"

Task 5: Calibration API Routes

Files:

  • Create: src/app/api/jobs/[jobId]/calibration/score/route.ts

  • Create: src/app/api/jobs/[jobId]/publications/route.ts

  • Create: src/app/api/publications/[publicationId]/performance/route.ts

  • Modify: src/app/api/__tests__/jobs.test.ts

  • Step 1: Write route tests

In src/app/api/__tests__/jobs.test.ts, import the new routes:

import { POST as scoreJob } from "../jobs/[jobId]/calibration/score/route";
import {
  GET as listPublications,
  POST as createPublication,
} from "../jobs/[jobId]/publications/route";
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";

Append this test inside describe("job API routes", () => { ... }) after the optimize success tests:

  it("scores a revision, registers publication, and records manual performance", async () => {
    const { job } = await createJobFixture();
    await confirmFactCard(
      request(validFactCard),
      params<{ jobId: string }>({ jobId: job.id }),
    );
    llmMocks.generateValidatedJson
      .mockResolvedValueOnce({
        title: "示例科技 GEO 内容优化方案",
        summary: "示例科技有限公司面向市场团队提供GEO内容优化服务。",
        body_markdown:
          "## 服务能力\n示例科技有限公司提供GEO内容优化服务。\n## 可信依据\n保留事实卡中的8年经验。",
        image_suggestions: [],
        changed_sections: ["title", "body"],
        requires_user_confirmation: [],
      })
      .mockResolvedValueOnce({ checks: [] });
    await optimizeJob(request({}), params<{ jobId: string }>({ jobId: job.id }));

    const scoreResponse = await scoreJob(
      request({}),
      params<{ jobId: string }>({ jobId: job.id }),
    );
    const scoreBody = (await scoreResponse.json()) as {
      scoringRun: { id: string; composite_score: number };
    };

    expect(scoreResponse.status).toBe(201);
    expect(scoreBody.scoringRun.composite_score).toBeGreaterThan(0);

    const publicationResponse = await createPublication(
      request({
        platform: "official_site",
        url: "https://example.com/article",
        published_at: "2026-06-24T12:00:00.000Z",
        notes: "官网首发",
      }),
      params<{ jobId: string }>({ jobId: job.id }),
    );
    const publicationBody = (await publicationResponse.json()) as {
      publication: { id: string; notes: string };
    };

    expect(publicationResponse.status).toBe(201);
    expect(publicationBody.publication.notes).toBe("官网首发");

    const listResponse = await listPublications(
      request({}),
      params<{ jobId: string }>({ jobId: job.id }),
    );
    const listBody = (await listResponse.json()) as {
      publications: Array<{ id: string }>;
    };

    expect(listResponse.status).toBe(200);
    expect(listBody.publications).toHaveLength(1);

    const performanceResponse = await recordPerformance(
      request({
        window_label: "T+7d",
        views: "1200",
        inquiries: "7",
        feedback_summary: "用户追问案例依据",
      }),
      params<{ publicationId: string }>({
        publicationId: publicationBody.publication.id,
      }),
    );
    const performanceBody = (await performanceResponse.json()) as {
      snapshot: { metrics: { views: number } };
      calibrationEvent: { observations: string[] };
    };

    expect(performanceResponse.status).toBe(201);
    expect(performanceBody.snapshot.metrics.views).toBe(1200);
    expect(performanceBody.calibrationEvent.observations.length).toBeGreaterThan(0);
  });
  • Step 2: Run failing route test

Run:

npm test -- src/app/api/__tests__/jobs.test.ts -t "scores a revision"

Expected: FAIL because route modules do not exist.

  • Step 3: Add scoring route

Create src/app/api/jobs/[jobId]/calibration/score/route.ts:

import { NextResponse } from "next/server";

import { requireApiAccess } from "../../../../../../lib/api/auth";
import { GEO_RUBRIC_V1, scoreOptimizedArticle } from "../../../../../../lib/calibration/scoring";
import { getRepositoryFromRuntime } from "../../../../../../lib/db/repository";

interface RouteContext {
  params: Promise<{ jobId: string }>;
}

export async function POST(request: Request, context: RouteContext) {
  const access = requireApiAccess(request);
  if (!access.ok) return access.response;

  const { jobId } = await context.params;
  const repository = getRepositoryFromRuntime();
  const article = await repository.getLatestOptimizedArticle(jobId);
  const qaReport = await repository.getLatestQaReport(jobId);
  if (!article || !qaReport) {
    return NextResponse.json(
      { error: "Optimize the article before scoring calibration" },
      { status: 409 },
    );
  }

  await repository.saveRubricVersion(GEO_RUBRIC_V1);
  const scoringRun = await repository.saveScoringRun(
    scoreOptimizedArticle({ jobId, article, qaReport }),
  );

  return NextResponse.json({ scoringRun }, { status: 201 });
}
  • Step 4: Add publication route

Create src/app/api/jobs/[jobId]/publications/route.ts:

import { NextResponse } from "next/server";

import { requireApiAccess } from "../../../../../lib/api/auth";
import { publicationInputSchema } from "../../../../../lib/calibration/validation";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";

interface RouteContext {
  params: Promise<{ jobId: string }>;
}

export async function GET(request: Request, context: RouteContext) {
  const access = requireApiAccess(request);
  if (!access.ok) return access.response;

  const { jobId } = await context.params;
  const repository = getRepositoryFromRuntime();
  return NextResponse.json({
    publications: await repository.listPublicationRecords(jobId),
  });
}

export async function POST(request: Request, context: RouteContext) {
  const access = requireApiAccess(request);
  if (!access.ok) return access.response;

  const { jobId } = await context.params;
  const repository = getRepositoryFromRuntime();
  const article = await repository.getLatestOptimizedArticle(jobId);
  if (!article?.revision) {
    return NextResponse.json(
      { error: "Optimize the article before registering publication" },
      { status: 409 },
    );
  }

  const input = publicationInputSchema.parse(await request.json());
  const publication = await repository.createPublicationRecord({
    job_id: jobId,
    revision: article.revision,
    platform: input.platform,
    url: input.url,
    published_at: input.published_at,
    status: "published",
    notes: input.notes,
  });

  return NextResponse.json({ publication }, { status: 201 });
}
  • Step 5: Add performance route

Create src/app/api/publications/[publicationId]/performance/route.ts:

import { NextResponse } from "next/server";

import { requireApiAccess } from "../../../../lib/api/auth";
import { createManualPerformanceAdapter } from "../../../../lib/calibration/manual-adapter";
import { createCalibrationEvent } from "../../../../lib/calibration/scoring";
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";

interface RouteContext {
  params: Promise<{ publicationId: string }>;
}

export async function POST(request: Request, context: RouteContext) {
  const access = requireApiAccess(request);
  if (!access.ok) return access.response;

  const { publicationId } = await context.params;
  const repository = getRepositoryFromRuntime();
  const publication = await repository.getPublicationRecord(publicationId);
  if (!publication) {
    return NextResponse.json({ error: "Publication not found" }, { status: 404 });
  }

  const scoringRun = await repository.getLatestScoringRun(
    publication.job_id,
    publication.revision,
  );
  const qaReport = await repository.getLatestQaReport(publication.job_id);
  if (!scoringRun || !qaReport) {
    return NextResponse.json(
      { error: "Score the optimized revision before recording performance" },
      { status: 409 },
    );
  }

  const adapter = createManualPerformanceAdapter();
  const snapshot = await repository.savePerformanceSnapshot(
    await adapter.fetch({
      publication,
      window_label: "manual",
      manualInput: await request.json(),
    }),
  );
  const calibrationEvent = await repository.saveCalibrationEvent(
    createCalibrationEvent({ scoringRun, qaReport, snapshot }),
  );

  return NextResponse.json({ snapshot, calibrationEvent }, { status: 201 });
}
  • Step 6: Run route tests

Run:

npm test -- src/app/api/__tests__/jobs.test.ts -t "scores a revision"

Expected: PASS.

  • Step 7: Commit Task 5
git add 'src/app/api/jobs/[jobId]/calibration/score/route.ts' 'src/app/api/jobs/[jobId]/publications/route.ts' 'src/app/api/publications/[publicationId]/performance/route.ts' src/app/api/__tests__/jobs.test.ts
git commit -m "新增发布校准接口"

Task 6: Frontend Calibration Panel

Files:

  • Create: src/components/performance-calibration-panel.tsx

  • Modify: src/app/page.tsx

  • Modify: src/app/globals.css

  • Step 1: Create panel component

Create src/components/performance-calibration-panel.tsx:

"use client";

import { useState } from "react";

import type { PublishPlatform } from "../lib/domain/types";

interface PerformanceCalibrationPanelProps {
  apiAccessKey: string;
  jobId: string | null;
  optimizedRevision: number | null;
}

interface ScoreResponse {
  scoringRun?: { id: string; composite_score: number; rationale: string };
  error?: string;
}

interface PublicationResponse {
  publication?: { id: string; url: string };
  error?: string;
}

interface PerformanceResponse {
  snapshot?: { metrics: Record<string, number> };
  calibrationEvent?: { observations: string[]; recommended_action: string };
  error?: string;
}

export function PerformanceCalibrationPanel({
  apiAccessKey,
  jobId,
  optimizedRevision,
}: PerformanceCalibrationPanelProps) {
  const [platform, setPlatform] = useState<PublishPlatform>("official_site");
  const [url, setUrl] = useState("");
  const [publishedAt, setPublishedAt] = useState(() =>
    new Date().toISOString().slice(0, 16),
  );
  const [publicationId, setPublicationId] = useState<string | null>(null);
  const [views, setViews] = useState("");
  const [clicks, setClicks] = useState("");
  const [inquiries, setInquiries] = useState("");
  const [feedbackSummary, setFeedbackSummary] = useState("");
  const [message, setMessage] = useState("");
  const [observations, setObservations] = useState<string[]>([]);

  const disabled = !jobId || !optimizedRevision;

  async function scoreRevision() {
    if (!jobId) return;
    setMessage("");
    const response = await fetch(`/api/jobs/${jobId}/calibration/score`, {
      method: "POST",
      headers: apiHeaders(apiAccessKey),
    });
    const body = (await response.json()) as ScoreResponse;
    if (!response.ok || !body.scoringRun) {
      setMessage(body.error ?? "评分失败");
      return;
    }
    setMessage(
      `校准评分 ${body.scoringRun.composite_score}/10${body.scoringRun.rationale}`,
    );
  }

  async function registerPublication() {
    if (!jobId) return;
    setMessage("");
    const response = await fetch(`/api/jobs/${jobId}/publications`, {
      method: "POST",
      headers: apiHeaders(apiAccessKey),
      body: JSON.stringify({
        platform,
        url,
        published_at: new Date(publishedAt).toISOString(),
        notes: "",
      }),
    });
    const body = (await response.json()) as PublicationResponse;
    if (!response.ok || !body.publication) {
      setMessage(body.error ?? "发布记录保存失败");
      return;
    }
    setPublicationId(body.publication.id);
    setMessage("发布记录已保存。");
  }

  async function recordPerformance() {
    if (!publicationId) return;
    setMessage("");
    const response = await fetch(`/api/publications/${publicationId}/performance`, {
      method: "POST",
      headers: apiHeaders(apiAccessKey),
      body: JSON.stringify({
        window_label: "T+7d",
        views,
        clicks,
        inquiries,
        feedback_summary: feedbackSummary,
      }),
    });
    const body = (await response.json()) as PerformanceResponse;
    if (!response.ok || !body.calibrationEvent) {
      setMessage(body.error ?? "表现数据保存失败");
      return;
    }
    setObservations(body.calibrationEvent.observations);
    setMessage(body.calibrationEvent.recommended_action);
  }

  return (
    <section className="panel stack">
      <div className="panel-heading">
        <span>发布表现校准</span>
        <button disabled={disabled} onClick={scoreRevision} type="button">
          生成评分
        </button>
      </div>
      <label>
        <span>发布平台</span>
        <select
          disabled={disabled}
          value={platform}
          onChange={(event) => setPlatform(event.target.value as PublishPlatform)}
        >
          <option value="official_site">官网文章</option>
          <option value="media_article">媒体稿</option>
          <option value="comparison_review">对比评测</option>
          <option value="recommendation_list">推荐榜单</option>
        </select>
      </label>
      <label>
        <span>发布链接</span>
        <input
          disabled={disabled}
          placeholder="https://example.com/article"
          value={url}
          onChange={(event) => setUrl(event.target.value)}
        />
      </label>
      <label>
        <span>发布时间</span>
        <input
          disabled={disabled}
          type="datetime-local"
          value={publishedAt}
          onChange={(event) => setPublishedAt(event.target.value)}
        />
      </label>
      <button disabled={disabled || !url} onClick={registerPublication} type="button">
        保存发布记录
      </button>
      <div className="calibration-metrics">
        <label>
          <span>阅读/浏览</span>
          <input value={views} onChange={(event) => setViews(event.target.value)} />
        </label>
        <label>
          <span>点击</span>
          <input value={clicks} onChange={(event) => setClicks(event.target.value)} />
        </label>
        <label>
          <span>询盘</span>
          <input
            value={inquiries}
            onChange={(event) => setInquiries(event.target.value)}
          />
        </label>
      </div>
      <label>
        <span>反馈摘要</span>
        <textarea
          value={feedbackSummary}
          onChange={(event) => setFeedbackSummary(event.target.value)}
        />
      </label>
      <button
        disabled={!publicationId}
        onClick={recordPerformance}
        type="button"
      >
        记录表现并生成复盘
      </button>
      {message && <p className="status-text">{message}</p>}
      {observations.length > 0 && (
        <ul className="calibration-observations">
          {observations.map((observation) => (
            <li key={observation}>{observation}</li>
          ))}
        </ul>
      )}
    </section>
  );
}

function apiHeaders(apiAccessKey: string) {
  const headers: Record<string, string> = { "content-type": "application/json" };
  if (apiAccessKey) {
    headers["x-api-key"] = apiAccessKey;
  }
  return headers;
}
  • Step 2: Wire panel into page

In src/app/page.tsx, import:

import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";

Add this JSX after <QaReportPanel report={qaReport} />:

        <PerformanceCalibrationPanel
          apiAccessKey={apiAccessKey}
          jobId={jobId}
          optimizedRevision={optimizedArticle?.revision ?? null}
        />
  • Step 3: Add compact styles

Append to src/app/globals.css before the media query:

.calibration-metrics {
  display: grid;
  gap: 0.75rem;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.calibration-observations {
  border: 1px solid #e5e9f0;
  border-radius: 8px;
  color: #586174;
  display: grid;
  gap: 0.45rem;
  margin: 0;
  padding: 0.75rem 0.75rem 0.75rem 1.4rem;
}

Inside the existing @media (max-width: 900px) block, change the selector:

  .workflow-grid,
  .two-col,
  .calibration-metrics {
    grid-template-columns: 1fr;
  }
  • Step 4: Run a TypeScript/build check

Run:

npm run lint

Expected: PASS.

  • Step 5: Commit Task 6
git add src/components/performance-calibration-panel.tsx src/app/page.tsx src/app/globals.css
git commit -m "新增发布表现校准界面"

Task 7: Full Verification

Files:

  • No source edits unless verification reveals an issue.

  • Step 1: Run focused tests

Run:

npm test -- src/lib/calibration/__tests__/validation.test.ts src/lib/calibration/__tests__/scoring.test.ts src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/d1-repository.test.ts src/app/api/__tests__/jobs.test.ts

Expected: PASS.

  • Step 2: Run full validation suite

Run:

npm run lint
npm test
npm run build

Expected: all commands exit 0.

  • Step 3: Scan for public-repo credential risk

Run:

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

Expected: no newly introduced credential values. Documentation-only mentions of cookie safety are acceptable.

  • Step 4: Review migration and local schema parity

Run:

rg -n "rubric_versions|scoring_runs|publication_records|performance_snapshots|calibration_events" src/lib/db/schema.ts migrations/0002_publication_performance_calibration.sql

Expected: all five table names appear in both files.

  • Step 5: Commit final fixes if needed

Only if verification required changes:

git add <changed-files>
git commit -m "修复发布校准验证问题"

If no changes were needed, do not create an empty commit.

Self-Review

  • Spec coverage: The plan implements the optional PerformanceCalibrator, pre-publish scoring, publication records, manual performance snapshots, adapter boundary through PerformanceAdapter, calibration events, sparse metrics, and D1 migration-only persistence.
  • Placeholder scan: No TBD, TODO, "implement later", or cross-task shorthand remains. Each code-changing task includes concrete code or exact implementation signatures.
  • Type consistency: The plan uses RubricVersion, ScoringRun, PublicationRecord, PerformanceSnapshot, CalibrationEvent, and PerformanceAdapter consistently across validation, service, repository, API, and UI tasks.