From 26b09da2738174af6aaa8e944409f9520b92370f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 24 Jun 2026 11:08:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=8F=91=E5=B8=83=E6=A0=A1?= =?UTF-8?q?=E5=87=86=E4=BB=93=E5=82=A8=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/db/__tests__/d1-repository.test.ts | 33 +++ src/lib/db/__tests__/repository.test.ts | 75 +++++++ src/lib/db/d1-repository.ts | 209 ++++++++++++++++++ src/lib/db/repositories.ts | 237 +++++++++++++++++++++ src/lib/db/repository.ts | 18 ++ src/lib/db/sqlite-repository.ts | 36 ++++ 6 files changed, 608 insertions(+) diff --git a/src/lib/db/__tests__/d1-repository.test.ts b/src/lib/db/__tests__/d1-repository.test.ts index 68209cd..41c0a63 100644 --- a/src/lib/db/__tests__/d1-repository.test.ts +++ b/src/lib/db/__tests__/d1-repository.test.ts @@ -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", + ); + }); }); diff --git a/src/lib/db/__tests__/repository.test.ts b/src/lib/db/__tests__/repository.test.ts index c1595d2..de25532 100644 --- a/src/lib/db/__tests__/repository.test.ts +++ b/src/lib/db/__tests__/repository.test.ts @@ -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(["表现高于预期"]); + }); }); diff --git a/src/lib/db/d1-repository.ts b/src/lib/db/d1-repository.ts index 1ba3af6..a0596ad 100644 --- a/src/lib/db/d1-repository.ts +++ b/src/lib/db/d1-repository.ts @@ -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>(row.dimension_scores), + }; +} + +function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot { + return { + ...row, + raw_reference: row.raw_reference ?? undefined, + metrics: parseJson(row.metrics), + }; +} + export function createD1Repository(db: D1Database): AppRepository { return { async createBrandTemplate(input) { @@ -299,5 +354,159 @@ export function createD1Repository(db: D1Database): AppRepository { .first(); return row ? parseJson(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(); + 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; + }, }; } diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index 2882d0f..d7b09ee 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -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>(row.dimension_scores), + }; +} + +function toPerformanceSnapshot(row: PerformanceSnapshotRow): PerformanceSnapshot { + return { + ...row, + raw_reference: row.raw_reference ?? undefined, + metrics: parseJson(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(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, +) { + 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; + }); +} diff --git a/src/lib/db/repository.ts b/src/lib/db/repository.ts index 90180f8..0cf11e2 100644 --- a/src/lib/db/repository.ts +++ b/src/lib/db/repository.ts @@ -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; saveQaReport(jobId: string, revision: number, report: QaReport): Promise; getLatestQaReport(jobId: string): Promise; + 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; } interface RuntimeRepositoryOptions { diff --git a/src/lib/db/sqlite-repository.ts b/src/lib/db/sqlite-repository.ts index 6d10237..4f9ff69 100644 --- a/src/lib/db/sqlite-repository.ts +++ b/src/lib/db/sqlite-repository.ts @@ -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)); + }, }; }