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

2406 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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:
```bash
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`:
```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`:
```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:
```bash
npm test -- src/lib/calibration/__tests__/validation.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit Task 1**
```bash
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`:
```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:
```bash
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`:
```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`:
```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:
```bash
npm test -- src/lib/calibration/__tests__/scoring.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit Task 2**
```bash
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 after `qa_reports`:
```sql
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:
```sql
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:
```bash
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**
```bash
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`:
```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:
```bash
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`:
```ts
import type {
CalibrationEvent,
PerformanceSnapshot,
PublicationRecord,
RubricVersion,
ScoringRun,
} from "../calibration/types";
```
Add these interface methods:
```ts
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:
```ts
import type {
CalibrationEvent,
PerformanceSnapshot,
PublicationRecord,
RubricVersion,
ScoringRun,
} from "../calibration/types";
```
Add row interfaces near existing row interfaces:
```ts
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:
```ts
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`:
```ts
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:
```ts
createPublicationRecord,
getLatestScoringRun,
getPublicationRecord,
listPerformanceSnapshots,
listPublicationRecords,
saveCalibrationEvent,
savePerformanceSnapshot,
saveRubricVersion,
saveScoringRun,
```
Add implementations in `createSqliteRepository`:
```ts
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:
```ts
import type {
CalibrationEvent,
PerformanceSnapshot,
PublicationRecord,
RubricVersion,
ScoringRun,
} from "../calibration/types";
```
Add row interfaces near the existing D1 row interfaces:
```ts
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`:
```ts
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`:
```ts
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`:
```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:
```bash
npm test -- src/lib/db/__tests__/repository.test.ts src/lib/db/__tests__/d1-repository.test.ts
```
Expected: PASS.
- [ ] **Step 9: Commit Task 4**
```bash
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:
```ts
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:
```ts
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:
```bash
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`:
```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`:
```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`:
```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:
```bash
npm test -- src/app/api/__tests__/jobs.test.ts -t "scores a revision"
```
Expected: PASS.
- [ ] **Step 7: Commit Task 5**
```bash
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`:
```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:
```ts
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
```
Add this JSX after `<QaReportPanel report={qaReport} />`:
```tsx
<PerformanceCalibrationPanel
apiAccessKey={apiAccessKey}
jobId={jobId}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
```
- [ ] **Step 3: Add compact styles**
Append to `src/app/globals.css` before the media query:
```css
.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:
```css
.workflow-grid,
.two-col,
.calibration-metrics {
grid-template-columns: 1fr;
}
```
- [ ] **Step 4: Run a TypeScript/build check**
Run:
```bash
npm run lint
```
Expected: PASS.
- [ ] **Step 5: Commit Task 6**
```bash
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:
```bash
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:
```bash
npm run lint
npm test
npm run build
```
Expected: all commands exit 0.
- [ ] **Step 3: Scan for public-repo credential risk**
Run:
```bash
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:
```bash
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:
```bash
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.