新增发布校准领域类型

This commit is contained in:
Codex
2026-06-24 11:01:07 +08:00
parent 2529a8d030
commit 5a46730302
3 changed files with 310 additions and 0 deletions
@@ -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/);
});
});
+100
View File
@@ -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<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;
}
+157
View File
@@ -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<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.custom<PerformanceSource>(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<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>;