diff --git a/migrations/0002_publication_performance_calibration.sql b/migrations/0002_publication_performance_calibration.sql new file mode 100644 index 0000000..f212fbb --- /dev/null +++ b/migrations/0002_publication_performance_calibration.sql @@ -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); diff --git a/src/app/api/__tests__/jobs.test.ts b/src/app/api/__tests__/jobs.test.ts index 29602ea..f6f024e 100644 --- a/src/app/api/__tests__/jobs.test.ts +++ b/src/app/api/__tests__/jobs.test.ts @@ -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( diff --git a/src/app/api/jobs/[jobId]/calibration/score/route.ts b/src/app/api/jobs/[jobId]/calibration/score/route.ts new file mode 100644 index 0000000..033f205 --- /dev/null +++ b/src/app/api/jobs/[jobId]/calibration/score/route.ts @@ -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 }); +} diff --git a/src/app/api/jobs/[jobId]/publications/route.ts b/src/app/api/jobs/[jobId]/publications/route.ts new file mode 100644 index 0000000..364381c --- /dev/null +++ b/src/app/api/jobs/[jobId]/publications/route.ts @@ -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 }); + } +} diff --git a/src/app/api/publications/[publicationId]/performance/route.ts b/src/app/api/publications/[publicationId]/performance/route.ts new file mode 100644 index 0000000..6be6470 --- /dev/null +++ b/src/app/api/publications/[publicationId]/performance/route.ts @@ -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 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 414d24d..07a96d4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } diff --git a/src/app/page.tsx b/src/app/page.tsx index c1d738d..d6ef980 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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} /> + ); diff --git a/src/components/performance-calibration-panel.tsx b/src/components/performance-calibration-panel.tsx new file mode 100644 index 0000000..59fd3d9 --- /dev/null +++ b/src/components/performance-calibration-panel.tsx @@ -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 }; + calibrationEvent?: { observations: string[]; recommended_action: string }; + error?: string; +} + +export function PerformanceCalibrationPanel({ + apiAccessKey, + jobId, + optimizedRevision, +}: PerformanceCalibrationPanelProps) { + const [platform, setPlatform] = useState("official_site"); + const [url, setUrl] = useState(""); + const [publishedAt, setPublishedAt] = useState(() => + new Date().toISOString().slice(0, 16), + ); + const [publicationId, setPublicationId] = useState(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([]); + + 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 ( +
+
+ 发布表现校准 + +
+ + + + +
+ + + +
+