# 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; 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; } 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; 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; 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; 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; export const calibrationDirectionSchema = z.enum([ "better_than_expected", "as_expected", "worse_than_expected", "needs_more_data", ]) satisfies z.ZodType; 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; ``` - [ ] **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) { 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) { 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; } { 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; saveScoringRun(run: ScoringRun): Promise; getLatestScoringRun(jobId: string, revision: number): Promise; createPublicationRecord( input: Omit, ): Promise; listPublicationRecords(jobId: string): Promise; getPublicationRecord(id: string): Promise; savePerformanceSnapshot(snapshot: PerformanceSnapshot): Promise; listPerformanceSnapshots(publicationId: string): Promise; saveCalibrationEvent(event: CalibrationEvent): Promise; ``` - [ ] **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(row.dimensions), is_active: Boolean(row.is_active), }; } function toScoringRun(row: ScoringRunRow): ScoringRun { return { ...row, dimension_scores: parseJson>(row.dimension_scores), }; } function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot { return { ...row, raw_reference: row.raw_reference ?? undefined, metrics: parseJson(row.metrics), }; } function toCalibrationEvent(row: CalibrationEventRow): CalibrationEvent { return { ...row, observations: parseJson(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, ) { 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(row.dimensions), is_active: Boolean(row.is_active), }; } function toScoringRun(row: ScoringRunRow): ScoringRun { return { ...row, dimension_scores: parseJson>(row.dimension_scores), }; } function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot { return { ...row, raw_reference: row.raw_reference ?? undefined, metrics: parseJson(row.metrics), }; } function toCalibrationEvent(row: CalibrationEventRow): CalibrationEvent { return { ...row, observations: parseJson(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(); 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(); return result.results; }, async getPublicationRecord(id) { return db .prepare("select * from publication_records where id = ?") .bind(id) .first(); }, 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(); 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 }; calibrationEvent?: { observations: string[]; recommended_action: string }; error?: string; } export function PerformanceCalibrationPanel({ apiAccessKey, jobId, optimizedRevision, }: PerformanceCalibrationPanelProps) { const [platform, setPlatform] = useState("official_site"); const [url, setUrl] = useState(""); const [publishedAt, setPublishedAt] = useState(() => new Date().toISOString().slice(0, 16), ); const [publicationId, setPublicationId] = useState(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([]); 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 (
发布表现校准