接入发布校准仓储接口

This commit is contained in:
Codex
2026-06-24 11:08:23 +08:00
parent f3d26e148a
commit 26b09da273
6 changed files with 608 additions and 0 deletions
@@ -62,4 +62,37 @@ describe("createD1Repository", () => {
export_paths: {},
});
});
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",
);
});
});
+75
View File
@@ -36,4 +36,79 @@ describe("createSqliteRepository", () => {
export_paths: {},
});
});
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(["表现高于预期"]);
});
});
+209
View File
@@ -1,5 +1,10 @@
import { nanoid } from "nanoid";
import type {
PerformanceSnapshot,
PublicationRecord,
ScoringRun,
} from "../calibration/types";
import type {
ConfirmedFactCard,
ImageInput,
@@ -58,6 +63,41 @@ interface QaReportRow {
report: 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;
}
function nowIso() {
return new Date().toISOString();
}
@@ -91,6 +131,21 @@ function toArticleJob(row: ArticleJobRow): ArticleJob {
};
}
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),
};
}
export function createD1Repository(db: D1Database): AppRepository {
return {
async createBrandTemplate(input) {
@@ -299,5 +354,159 @@ export function createD1Repository(db: D1Database): AppRepository {
.first<QaReportRow>();
return row ? parseJson<QaReport>(row.report) : null;
},
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;
},
};
}
+237
View File
@@ -1,5 +1,12 @@
import { nanoid } from "nanoid";
import type {
CalibrationEvent,
PerformanceSnapshot,
PublicationRecord,
RubricVersion,
ScoringRun,
} from "../calibration/types";
import type {
ConfirmedFactCard,
ImageInput,
@@ -102,6 +109,41 @@ interface QaReportRow {
report: 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;
}
function nowIso() {
return new Date().toISOString();
}
@@ -145,6 +187,21 @@ function toArticleJob(row: ArticleJobRow): ArticleJob {
};
}
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),
};
}
export function createBrandTemplate(
dbPath: string | undefined,
input: NewBrandTemplate,
@@ -380,3 +437,183 @@ export function getLatestQaReport(dbPath: string | undefined, jobId: string) {
return row ? parseJson<QaReport>(row.report) : null;
});
}
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 PublicationRecordRow),
);
}
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;
});
}
+18
View File
@@ -1,3 +1,10 @@
import type {
CalibrationEvent,
PerformanceSnapshot,
PublicationRecord,
RubricVersion,
ScoringRun,
} from "../calibration/types";
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type {
ArticleJob,
@@ -28,6 +35,17 @@ export interface AppRepository {
getLatestOptimizedArticle(jobId: string): Promise<OptimizedArticle | null>;
saveQaReport(jobId: string, revision: number, report: QaReport): Promise<QaReport>;
getLatestQaReport(jobId: string): Promise<QaReport | null>;
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>;
}
interface RuntimeRepositoryOptions {
+36
View File
@@ -3,15 +3,24 @@ import type { AppRepository } from "./repository";
import {
createArticleJob,
createBrandTemplate,
createPublicationRecord,
getArticleJob,
getBrandTemplate,
getFactCard,
getLatestOptimizedArticle,
getLatestQaReport,
getLatestScoringRun,
getPublicationRecord,
listBrandTemplates,
listPerformanceSnapshots,
listPublicationRecords,
saveCalibrationEvent,
saveFactCard,
saveOptimizedArticle,
savePerformanceSnapshot,
saveQaReport,
saveRubricVersion,
saveScoringRun,
updateArticleJob,
type ArticleJob,
type NewArticleJob,
@@ -59,5 +68,32 @@ export function createSqliteRepository(dbPath?: string): AppRepository {
getLatestQaReport(jobId: string) {
return Promise.resolve(getLatestQaReport(dbPath, jobId));
},
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));
},
};
}