Compare commits

8 Commits
Author SHA1 Message Date
czj 714eb5aba6 合并发布表现校准功能 2026-06-24 13:22:25 +08:00
Codex aa19696edb 修复发布校准验证问题 2026-06-24 11:19:01 +08:00
Codex ed176b9fac 新增发布表现校准界面 2026-06-24 11:16:15 +08:00
Codex dafbe2e2e9 新增发布校准接口 2026-06-24 11:14:07 +08:00
Codex 26b09da273 接入发布校准仓储接口 2026-06-24 11:08:23 +08:00
Codex f3d26e148a 新增发布校准数据库结构 2026-06-24 11:04:38 +08:00
Codex 7cd54738cc 新增发布表现评分服务 2026-06-24 11:03:29 +08:00
Codex 5a46730302 新增发布校准领域类型 2026-06-24 11:01:07 +08:00
22 changed files with 1918 additions and 1 deletions
@@ -0,0 +1,74 @@
CREATE TABLE IF NOT EXISTS rubric_versions (
id TEXT PRIMARY KEY,
version TEXT NOT NULL,
name TEXT NOT NULL,
dimensions TEXT NOT NULL,
formula TEXT NOT NULL,
is_active INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scoring_runs (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
revision INTEGER NOT NULL,
rubric_version_id TEXT NOT NULL,
dimension_scores TEXT NOT NULL,
composite_score REAL NOT NULL,
rationale TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (job_id, revision)
REFERENCES optimized_articles(job_id, revision) ON DELETE CASCADE,
FOREIGN KEY (rubric_version_id) REFERENCES rubric_versions(id)
);
CREATE TABLE IF NOT EXISTS publication_records (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
revision INTEGER NOT NULL,
platform TEXT NOT NULL,
url TEXT NOT NULL,
published_at TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (job_id, revision)
REFERENCES optimized_articles(job_id, revision) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS performance_snapshots (
id TEXT PRIMARY KEY,
publication_id TEXT NOT NULL,
source TEXT NOT NULL,
window_label TEXT NOT NULL,
metrics TEXT NOT NULL,
feedback_summary TEXT NOT NULL,
raw_reference TEXT,
snapshot_at TEXT NOT NULL,
FOREIGN KEY (publication_id) REFERENCES publication_records(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS calibration_events (
id TEXT PRIMARY KEY,
publication_id TEXT NOT NULL,
scoring_run_id TEXT NOT NULL,
performance_snapshot_id TEXT NOT NULL,
direction TEXT NOT NULL,
observations TEXT NOT NULL,
recommended_action TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (publication_id) REFERENCES publication_records(id) ON DELETE CASCADE,
FOREIGN KEY (scoring_run_id) REFERENCES scoring_runs(id) ON DELETE CASCADE,
FOREIGN KEY (performance_snapshot_id)
REFERENCES performance_snapshots(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_scoring_runs_job_revision
ON scoring_runs(job_id, revision);
CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision
ON publication_records(job_id, revision);
CREATE INDEX IF NOT EXISTS idx_performance_snapshots_publication
ON performance_snapshots(publication_id);
+95
View File
@@ -19,10 +19,16 @@ vi.mock("../../../lib/llm/client", async () => {
});
import { POST as confirmFactCard } from "../jobs/[jobId]/confirm-fact-card/route";
import { POST as scoreJob } from "../jobs/[jobId]/calibration/score/route";
import { GET as downloadExport } from "../jobs/[jobId]/exports/[fileName]/route";
import { POST as optimizeJob } from "../jobs/[jobId]/optimize/route";
import {
GET as listPublications,
POST as createPublication,
} from "../jobs/[jobId]/publications/route";
import { GET as getJobProgress } from "../jobs/[jobId]/progress/route";
import { POST as createJob } from "../jobs/route";
import { POST as recordPerformance } from "../publications/[publicationId]/performance/route";
const validFactCard = {
company_full_name: "Example Technology Co., Ltd.",
@@ -243,6 +249,95 @@ describe("job API routes", () => {
expect(body.timing.steps.every((step) => step.duration_ms >= 0)).toBe(true);
});
it("scores a revision, registers publication, and records manual performance", async () => {
const { job } = await createJobFixture();
await confirmFactCard(
request(validFactCard),
params<{ jobId: string }>({ jobId: job.id }),
);
llmMocks.generateValidatedJson.mockReset();
llmMocks.generateValidatedJson
.mockResolvedValueOnce({
title: "示例科技 GEO 内容优化方案",
summary:
"Example Technology Co., Ltd. 面向市场团队提供 GEO optimization 服务。",
body_markdown:
"## 服务能力\nExample Technology Co., Ltd. 提供 GEO optimization 服务。\n## 可信依据\n保留事实卡中的8年经验。",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
})
.mockResolvedValueOnce({ checks: [] });
const optimizeResponse = await optimizeJob(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
const optimizeBody = (await optimizeResponse.json()) as unknown;
if (optimizeResponse.status !== 200) {
throw new Error(`optimize failed: ${JSON.stringify(optimizeBody)}`);
}
expect(optimizeResponse.status).toBe(200);
const scoreResponse = await scoreJob(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
const scoreBody = (await scoreResponse.json()) as {
scoringRun: { id: string; composite_score: number };
};
expect(scoreResponse.status).toBe(201);
expect(scoreBody.scoringRun.composite_score).toBeGreaterThan(0);
const publicationResponse = await createPublication(
request({
platform: "official_site",
url: "https://example.com/article",
published_at: "2026-06-24T12:00:00.000Z",
notes: "官网首发",
}),
params<{ jobId: string }>({ jobId: job.id }),
);
const publicationBody = (await publicationResponse.json()) as {
publication: { id: string; notes: string };
};
expect(publicationResponse.status).toBe(201);
expect(publicationBody.publication.notes).toBe("官网首发");
const listResponse = await listPublications(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
const listBody = (await listResponse.json()) as {
publications: Array<{ id: string }>;
};
expect(listResponse.status).toBe(200);
expect(listBody.publications).toHaveLength(1);
const performanceResponse = await recordPerformance(
request({
window_label: "T+7d",
views: "1200",
inquiries: "7",
feedback_summary: "用户追问案例依据",
}),
params<{ publicationId: string }>({
publicationId: publicationBody.publication.id,
}),
);
const performanceBody = (await performanceResponse.json()) as {
snapshot: { metrics: { views: number } };
calibrationEvent: { observations: string[] };
};
expect(performanceResponse.status).toBe(201);
expect(performanceBody.snapshot.metrics.views).toBe(1200);
expect(performanceBody.calibrationEvent.observations.length).toBeGreaterThan(0);
});
it("returns backend-visible optimization progress after a job runs", async () => {
const { job } = await createJobFixture();
await confirmFactCard(
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../../lib/api/auth";
import {
GEO_RUBRIC_V1,
scoreOptimizedArticle,
} from "../../../../../../lib/calibration/scoring";
import { getRepositoryFromRuntime } from "../../../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId } = await context.params;
const repository = getRepositoryFromRuntime();
const article = await repository.getLatestOptimizedArticle(jobId);
const qaReport = await repository.getLatestQaReport(jobId);
if (!article || !qaReport) {
return NextResponse.json(
{ error: "Optimize the article before scoring calibration" },
{ status: 409 },
);
}
await repository.saveRubricVersion(GEO_RUBRIC_V1);
const scoringRun = await repository.saveScoringRun(
scoreOptimizedArticle({ jobId, article, qaReport }),
);
return NextResponse.json({ scoringRun }, { status: 201 });
}
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { publicationInputSchema } from "../../../../../lib/calibration/validation";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId } = await context.params;
const repository = getRepositoryFromRuntime();
return NextResponse.json({
publications: await repository.listPublicationRecords(jobId),
});
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId } = await context.params;
const repository = getRepositoryFromRuntime();
const article = await repository.getLatestOptimizedArticle(jobId);
if (!article?.revision) {
return NextResponse.json(
{ error: "Optimize the article before registering publication" },
{ status: 409 },
);
}
try {
const input = publicationInputSchema.parse(await request.json());
const publication = await repository.createPublicationRecord({
job_id: jobId,
revision: article.revision,
platform: input.platform,
url: input.url,
published_at: input.published_at,
status: "published",
notes: input.notes,
});
return NextResponse.json({ publication }, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid publication";
return NextResponse.json({ error: message }, { status: 400 });
}
}
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { createManualPerformanceAdapter } from "../../../../../lib/calibration/manual-adapter";
import { createCalibrationEvent } from "../../../../../lib/calibration/scoring";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
interface RouteContext {
params: Promise<{ publicationId: string }>;
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { publicationId } = await context.params;
const repository = getRepositoryFromRuntime();
const publication = await repository.getPublicationRecord(publicationId);
if (!publication) {
return NextResponse.json({ error: "Publication not found" }, { status: 404 });
}
const scoringRun = await repository.getLatestScoringRun(
publication.job_id,
publication.revision,
);
const qaReport = await repository.getLatestQaReport(publication.job_id);
if (!scoringRun || !qaReport) {
return NextResponse.json(
{ error: "Score the optimized revision before recording performance" },
{ status: 409 },
);
}
try {
const adapter = createManualPerformanceAdapter();
const snapshot = await repository.savePerformanceSnapshot(
await adapter.fetch({
publication,
window_label: "manual",
manualInput: await request.json(),
}),
);
const calibrationEvent = await repository.saveCalibrationEvent(
createCalibrationEvent({ scoringRun, qaReport, snapshot }),
);
return NextResponse.json({ snapshot, calibrationEvent }, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid performance";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+18 -1
View File
@@ -295,9 +295,26 @@ h3 {
padding: 0.25rem 0.55rem;
}
.calibration-metrics {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.calibration-observations {
border: 1px solid #e5e9f0;
border-radius: 8px;
color: #586174;
display: grid;
gap: 0.45rem;
margin: 0;
padding: 0.75rem 0.75rem 0.75rem 1.4rem;
}
@media (max-width: 900px) {
.workflow-grid,
.two-col {
.two-col,
.calibration-metrics {
grid-template-columns: 1fr;
}
+7
View File
@@ -11,6 +11,7 @@ import {
toConfirmedFactCard,
} from "../components/fact-card-editor";
import { OptimizedPreview } from "../components/optimized-preview";
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
import { ProgressPanel } from "../components/progress-panel";
import { QaReportPanel } from "../components/qa-report-panel";
import type {
@@ -270,6 +271,12 @@ export default function Home() {
jobId={jobId}
/>
<QaReportPanel report={qaReport} />
<PerformanceCalibrationPanel
apiAccessKey={apiAccessKey}
jobId={jobId}
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
</div>
</main>
);
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import type { PublishPlatform } from "../lib/domain/types";
interface PerformanceCalibrationPanelProps {
apiAccessKey: string;
jobId: string | null;
optimizedRevision: number | null;
}
interface ScoreResponse {
scoringRun?: { id: string; composite_score: number; rationale: string };
error?: string;
}
interface PublicationResponse {
publication?: { id: string; url: string };
error?: string;
}
interface PerformanceResponse {
snapshot?: { metrics: Record<string, number> };
calibrationEvent?: { observations: string[]; recommended_action: string };
error?: string;
}
export function PerformanceCalibrationPanel({
apiAccessKey,
jobId,
optimizedRevision,
}: PerformanceCalibrationPanelProps) {
const [platform, setPlatform] = useState<PublishPlatform>("official_site");
const [url, setUrl] = useState("");
const [publishedAt, setPublishedAt] = useState(() =>
new Date().toISOString().slice(0, 16),
);
const [publicationId, setPublicationId] = useState<string | null>(null);
const [views, setViews] = useState("");
const [clicks, setClicks] = useState("");
const [inquiries, setInquiries] = useState("");
const [feedbackSummary, setFeedbackSummary] = useState("");
const [message, setMessage] = useState("");
const [observations, setObservations] = useState<string[]>([]);
const disabled = !jobId || !optimizedRevision;
async function scoreRevision() {
if (!jobId) return;
setMessage("");
const response = await fetch(`/api/jobs/${jobId}/calibration/score`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
});
const body = (await response.json()) as ScoreResponse;
if (!response.ok || !body.scoringRun) {
setMessage(body.error ?? "评分失败");
return;
}
setMessage(
`校准评分 ${body.scoringRun.composite_score}/10${body.scoringRun.rationale}`,
);
}
async function registerPublication() {
if (!jobId || !publishedAt) return;
setMessage("");
const response = await fetch(`/api/jobs/${jobId}/publications`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
platform,
url,
published_at: new Date(publishedAt).toISOString(),
notes: "",
}),
});
const body = (await response.json()) as PublicationResponse;
if (!response.ok || !body.publication) {
setMessage(body.error ?? "发布记录保存失败");
return;
}
setPublicationId(body.publication.id);
setMessage("发布记录已保存。");
}
async function recordPerformance() {
if (!publicationId) return;
setMessage("");
const response = await fetch(`/api/publications/${publicationId}/performance`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
window_label: "T+7d",
views,
clicks,
inquiries,
feedback_summary: feedbackSummary,
}),
});
const body = (await response.json()) as PerformanceResponse;
if (!response.ok || !body.calibrationEvent) {
setMessage(body.error ?? "表现数据保存失败");
return;
}
setObservations(body.calibrationEvent.observations);
setMessage(body.calibrationEvent.recommended_action);
}
return (
<section className="panel stack">
<div className="panel-heading">
<span></span>
<button disabled={disabled} onClick={scoreRevision} type="button">
</button>
</div>
<label>
<span></span>
<select
disabled={disabled}
value={platform}
onChange={(event) => setPlatform(event.target.value as PublishPlatform)}
>
<option value="official_site"></option>
<option value="media_article">稿</option>
<option value="comparison_review"></option>
<option value="recommendation_list"></option>
</select>
</label>
<label>
<span></span>
<input
disabled={disabled}
placeholder="https://example.com/article"
value={url}
onChange={(event) => setUrl(event.target.value)}
/>
</label>
<label>
<span></span>
<input
disabled={disabled}
type="datetime-local"
value={publishedAt}
onChange={(event) => setPublishedAt(event.target.value)}
/>
</label>
<button
disabled={disabled || !url || !publishedAt}
onClick={registerPublication}
type="button"
>
</button>
<div className="calibration-metrics">
<label>
<span>/</span>
<input
min="0"
type="number"
value={views}
onChange={(event) => setViews(event.target.value)}
/>
</label>
<label>
<span></span>
<input
min="0"
type="number"
value={clicks}
onChange={(event) => setClicks(event.target.value)}
/>
</label>
<label>
<span></span>
<input
min="0"
type="number"
value={inquiries}
onChange={(event) => setInquiries(event.target.value)}
/>
</label>
</div>
<label>
<span></span>
<textarea
value={feedbackSummary}
onChange={(event) => setFeedbackSummary(event.target.value)}
/>
</label>
<button disabled={!publicationId} onClick={recordPerformance} type="button">
</button>
{message ? <p className="status-text">{message}</p> : null}
{observations.length > 0 ? (
<ul className="calibration-observations">
{observations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ul>
) : null}
</section>
);
}
function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
headers["x-api-key"] = apiAccessKey;
}
return headers;
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import type { OptimizedArticle, QaReport } from "../../domain/types";
import { createManualPerformanceAdapter } from "../manual-adapter";
import {
createCalibrationEvent,
GEO_RUBRIC_V1,
scoreOptimizedArticle,
} from "../scoring";
const article: OptimizedArticle = {
job_id: "job_123",
revision: 2,
title: "示例科技 GEO 内容优化方案",
summary: "示例科技有限公司面向市场团队提供GEO内容优化服务。",
body_markdown:
"## 服务能力\n示例科技有限公司提供GEO内容优化服务,帮助市场团队提升AI搜索可见性。\n## 可信依据\n文章保留事实卡中的8年经验描述。",
image_suggestions: [],
changed_sections: ["title", "body"],
requires_user_confirmation: [],
};
const qaReport: QaReport = {
job_id: "job_123",
revision: 2,
overall_status: "warn",
checks: [
{
rule_id: "hallucination_risk",
status: "warn",
evidence: "8年经验",
reason: "需要人工复核经验年限依据。",
suggested_fix: "确认事实卡。",
target_agent: "body",
},
],
};
describe("calibration scoring", () => {
it("scores an optimized article with the active GEO rubric", () => {
const run = scoreOptimizedArticle({
jobId: "job_123",
article,
qaReport,
});
expect(run.rubric_version_id).toBe(GEO_RUBRIC_V1.id);
expect(run.dimension_scores.fact_integrity).toBe(4);
expect(run.dimension_scores.readability).toBeGreaterThanOrEqual(4);
expect(run.composite_score).toBeGreaterThan(6);
expect(run.composite_score).toBeLessThanOrEqual(10);
});
it("normalizes manual performance input through the adapter boundary", async () => {
const adapter = createManualPerformanceAdapter();
const snapshot = await adapter.fetch({
publication: {
id: "pub_123",
job_id: "job_123",
revision: 2,
platform: "official_site",
url: "https://example.com/article",
published_at: "2026-06-24T12:00:00.000Z",
status: "published",
notes: "",
created_at: "2026-06-24T12:00:00.000Z",
updated_at: "2026-06-24T12:00:00.000Z",
},
window_label: "T+7d",
manualInput: {
window_label: "T+7d",
views: "1200",
inquiries: "7",
feedback_summary: "用户追问案例依据",
},
});
expect(snapshot.source).toBe("manual");
expect(snapshot.metrics).toEqual({ views: 1200, inquiries: 7 });
});
it("creates a reviewable calibration event without changing the article", () => {
const scoringRun = scoreOptimizedArticle({
jobId: "job_123",
article,
qaReport,
});
const event = createCalibrationEvent({
scoringRun,
qaReport,
snapshot: {
id: "perf_123",
publication_id: "pub_123",
source: "manual",
window_label: "T+7d",
metrics: { views: 1200, inquiries: 7 },
feedback_summary: "用户追问案例依据",
snapshot_at: "2026-07-01T12:00:00.000Z",
},
});
expect(event.direction).toBe("better_than_expected");
expect(event.observations.join(" ")).toContain("询盘");
expect(event.recommended_action).toContain("积累");
});
});
@@ -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/);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { nanoid } from "nanoid";
import type {
AdapterFetchInput,
PerformanceAdapter,
PerformanceSnapshot,
} from "./types";
import { manualPerformanceInputSchema, performanceSnapshotSchema } from "./validation";
interface ManualAdapterFetchInput extends AdapterFetchInput {
manualInput: unknown;
}
interface ManualPerformanceAdapter extends Omit<PerformanceAdapter, "fetch"> {
fetch(input: ManualAdapterFetchInput): Promise<PerformanceSnapshot>;
}
export function createManualPerformanceAdapter(): ManualPerformanceAdapter {
return {
source: "manual",
async fetch(input) {
const parsed = manualPerformanceInputSchema.parse(input.manualInput);
return performanceSnapshotSchema.parse({
id: `perf_${nanoid(10)}`,
publication_id: input.publication.id,
source: "manual",
window_label: parsed.window_label,
metrics: parsed.metrics,
feedback_summary: parsed.feedback_summary,
raw_reference: parsed.raw_reference,
snapshot_at: new Date().toISOString(),
});
},
};
}
+222
View File
@@ -0,0 +1,222 @@
import { nanoid } from "nanoid";
import type { OptimizedArticle, QaReport } from "../domain/types";
import type {
CalibrationContext,
CalibrationDirection,
CalibrationEvent,
RubricVersion,
ScoringRun,
} from "./types";
export const GEO_RUBRIC_V1: RubricVersion = {
id: "rubric_geo_v1",
version: "v1",
name: "GEO article performance rubric",
formula: "weighted_average_0_to_10",
is_active: true,
created_at: "2026-06-24T00:00:00.000Z",
dimensions: [
{
id: "fact_integrity",
label: "事实一致性",
weight: 2,
description: "事实、公司名、产品名和经验年限是否遵守事实卡。",
},
{
id: "platform_fit",
label: "平台适配",
weight: 1.5,
description: "表达是否匹配目标发布平台。",
},
{
id: "search_intent_fit",
label: "搜索意图匹配",
weight: 1.5,
description: "是否回答GEO/Search背后的用户问题。",
},
{
id: "answer_density",
label: "答案密度",
weight: 1.5,
description: "是否提供具体信息而不是泛泛宣传。",
},
{
id: "trust_signal_quality",
label: "信任信号质量",
weight: 1.5,
description: "可信依据是否具体、克制且可复核。",
},
{
id: "readability",
label: "可读性",
weight: 1,
description: "标题、摘要、正文是否清晰易读。",
},
],
};
interface ScoreOptimizedArticleInput {
jobId: string;
article: OptimizedArticle;
qaReport: QaReport;
}
export function scoreOptimizedArticle({
jobId,
article,
qaReport,
}: ScoreOptimizedArticleInput): ScoringRun {
const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
const dimensionScores = {
fact_integrity: scoreFactIntegrity(qaReport),
platform_fit: scoreRuleGroup(qaReport, ["platform_fit", "voice_consistency"]),
search_intent_fit: hasGeoIntent(combined) ? 4 : 2,
answer_density: scoreAnswerDensity(combined),
trust_signal_quality: scoreTrustSignals(combined, qaReport),
readability: scoreReadability(article),
};
return {
id: `score_${nanoid(10)}`,
job_id: jobId,
revision: article.revision ?? 1,
rubric_version_id: GEO_RUBRIC_V1.id,
dimension_scores: dimensionScores,
composite_score: weightedComposite(dimensionScores),
rationale: buildRationale(dimensionScores),
created_at: new Date().toISOString(),
};
}
function scoreFactIntegrity(report: QaReport) {
const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"];
const statuses = report.checks
.filter((check) => hardRules.includes(check.rule_id))
.map((check) => check.status);
if (statuses.includes("fail")) return 1;
if (statuses.includes("warn")) return 4;
return 5;
}
function scoreRuleGroup(report: QaReport, ruleIds: string[]) {
const statuses = report.checks
.filter((check) => ruleIds.includes(check.rule_id))
.map((check) => check.status);
if (statuses.includes("fail")) return 2;
if (statuses.includes("warn")) return 3;
return 4;
}
function hasGeoIntent(text: string) {
return /GEO|AI搜索|生成式引擎|搜索|可见性|问答|推荐/i.test(text);
}
function scoreAnswerDensity(text: string) {
const headings = (text.match(/^##\s+/gm) ?? []).length;
const concreteSignals = (
text.match(/服务|流程|方案|能力|团队|行业|客户|案例/g) ?? []
).length;
if (headings >= 2 && concreteSignals >= 8) return 5;
if (headings >= 1 && concreteSignals >= 4) return 4;
if (concreteSignals >= 2) return 3;
return 2;
}
function scoreTrustSignals(text: string, report: QaReport) {
const hallucination = report.checks.find(
(check) => check.rule_id === "hallucination_risk",
);
if (hallucination?.status === "fail") return 1;
const trustSignals = (text.match(/依据|经验|资质|案例|事实卡|复核|客户/g) ?? [])
.length;
if (hallucination?.status === "warn") return trustSignals >= 2 ? 3 : 2;
return trustSignals >= 2 ? 4 : 3;
}
function scoreReadability(article: OptimizedArticle) {
const hasLongSentence = `${article.summary}\n${article.body_markdown}`
.split(/[。!?.!?]/)
.some((sentence) => sentence.length > 180);
if (article.title.length > 42 || hasLongSentence) return 3;
return 4;
}
function weightedComposite(scores: Record<string, number>) {
const totalWeight = GEO_RUBRIC_V1.dimensions.reduce(
(sum, dimension) => sum + dimension.weight,
0,
);
const weighted = GEO_RUBRIC_V1.dimensions.reduce(
(sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight,
0,
);
return Math.round((weighted / totalWeight) * 2 * 10) / 10;
}
function buildRationale(scores: Record<string, number>) {
return `事实一致性 ${scores.fact_integrity}/5,平台适配 ${scores.platform_fit}/5,答案密度 ${scores.answer_density}/5,信任信号 ${scores.trust_signal_quality}/5。`;
}
export function createCalibrationEvent({
scoringRun,
qaReport,
snapshot,
}: CalibrationContext): CalibrationEvent {
const direction = inferDirection(scoringRun.composite_score, snapshot.metrics);
const observations = buildObservations(
direction,
scoringRun,
qaReport,
snapshot.feedback_summary,
);
return {
id: `cal_${nanoid(10)}`,
publication_id: snapshot.publication_id,
scoring_run_id: scoringRun.id,
performance_snapshot_id: snapshot.id,
direction,
observations,
recommended_action:
"先积累至少 5 篇同类样本,再评估是否调整 GEO rubric 权重。",
created_at: new Date().toISOString(),
};
}
function inferDirection(
composite: number,
metrics: { views?: number; clicks?: number; inquiries?: number },
): CalibrationDirection {
if (!metrics.views && !metrics.clicks && !metrics.inquiries) {
return "needs_more_data";
}
if ((metrics.inquiries ?? 0) >= 3 || (metrics.clicks ?? 0) >= 50) {
return "better_than_expected";
}
if ((metrics.views ?? 0) < 100 && composite >= 7) {
return "worse_than_expected";
}
return "as_expected";
}
function buildObservations(
direction: CalibrationDirection,
scoringRun: ScoringRun,
qaReport: QaReport,
feedbackSummary: string,
) {
const observations = [
`综合评分 ${scoringRun.composite_score}/10,真实表现方向为 ${direction}`,
];
if (qaReport.overall_status !== "pass") {
observations.push(`QA 状态为 ${qaReport.overall_status},需要和表现数据一起复盘。`);
}
if (feedbackSummary) {
observations.push(`反馈摘要:${feedbackSummary}`);
}
if ((scoringRun.dimension_scores.trust_signal_quality ?? 0) <= 3) {
observations.push("信任信号质量偏低,后续观察是否影响询盘。");
}
return observations;
}
+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>;
@@ -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",
);
});
});
@@ -49,9 +49,14 @@ describe("sqlite repositories", () => {
expect(tables).toEqual([
"article_jobs",
"brand_templates",
"calibration_events",
"fact_cards",
"optimized_articles",
"performance_snapshots",
"publication_records",
"qa_reports",
"rubric_versions",
"scoring_runs",
]);
});
+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 {
+75
View File
@@ -60,5 +60,80 @@ export function initializeSchema(db: Database.Database) {
foreign key (job_id, revision)
references optimized_articles(job_id, revision) on delete cascade
);
create table if not exists rubric_versions (
id text primary key,
version text not null,
name text not null,
dimensions text not null,
formula text not null,
is_active integer not null,
created_at text not null
);
create table if not exists scoring_runs (
id text primary key,
job_id text not null,
revision integer not null,
rubric_version_id text not null,
dimension_scores text not null,
composite_score real not null,
rationale text not null,
created_at text not null,
foreign key (job_id, revision)
references optimized_articles(job_id, revision) on delete cascade,
foreign key (rubric_version_id) references rubric_versions(id)
);
create table if not exists publication_records (
id text primary key,
job_id text not null,
revision integer not null,
platform text not null,
url text not null,
published_at text not null,
status text not null,
notes text not null,
created_at text not null,
updated_at text not null,
foreign key (job_id, revision)
references optimized_articles(job_id, revision) on delete cascade
);
create table if not exists performance_snapshots (
id text primary key,
publication_id text not null,
source text not null,
window_label text not null,
metrics text not null,
feedback_summary text not null,
raw_reference text,
snapshot_at text not null,
foreign key (publication_id) references publication_records(id) on delete cascade
);
create table if not exists calibration_events (
id text primary key,
publication_id text not null,
scoring_run_id text not null,
performance_snapshot_id text not null,
direction text not null,
observations text not null,
recommended_action text not null,
created_at text not null,
foreign key (publication_id) references publication_records(id) on delete cascade,
foreign key (scoring_run_id) references scoring_runs(id) on delete cascade,
foreign key (performance_snapshot_id)
references performance_snapshots(id) on delete cascade
);
create index if not exists idx_scoring_runs_job_revision
on scoring_runs(job_id, revision);
create index if not exists idx_publication_records_job_revision
on publication_records(job_id, revision);
create index if not exists idx_performance_snapshots_publication
on performance_snapshots(publication_id);
`);
}
+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));
},
};
}