diff --git a/src/lib/calibration/__tests__/validation.test.ts b/src/lib/calibration/__tests__/validation.test.ts new file mode 100644 index 0000000..c348905 --- /dev/null +++ b/src/lib/calibration/__tests__/validation.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + manualPerformanceInputSchema, + performanceSnapshotSchema, + publicationInputSchema, +} 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/); + }); +}); diff --git a/src/lib/calibration/types.ts b/src/lib/calibration/types.ts new file mode 100644 index 0000000..62db10f --- /dev/null +++ b/src/lib/calibration/types.ts @@ -0,0 +1,100 @@ +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; +} diff --git a/src/lib/calibration/validation.ts b/src/lib/calibration/validation.ts new file mode 100644 index 0000000..c948def --- /dev/null +++ b/src/lib/calibration/validation.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; + +import { publishPlatformSchema } from "../domain/validation"; +import type { + CalibrationDirection, + CalibrationEvent, + PerformanceMetrics, + PerformanceSnapshot, + PerformanceSource, + 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: raw_reference || undefined, + metrics: performanceMetricsSchema.parse(metrics), + })); + +function isPerformanceSource(value: unknown): value is PerformanceSource { + return ( + value === "manual" || + (typeof value === "string" && + value.startsWith("adapter:") && + value.length > "adapter:".length) + ); +} + +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.custom(isPerformanceSource), + 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;