新增发布校准领域类型
This commit is contained in:
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user