diff --git a/migrations/0003_unified_optimization_cases.sql b/migrations/0003_unified_optimization_cases.sql index 1d7f432..0cbbe6b 100644 --- a/migrations/0003_unified_optimization_cases.sql +++ b/migrations/0003_unified_optimization_cases.sql @@ -99,8 +99,36 @@ CREATE INDEX IF NOT EXISTS idx_publication_records_result_version CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision ON publication_records(job_id, revision); -ALTER TABLE scoring_runs ADD COLUMN result_version_id TEXT; -ALTER TABLE scoring_runs ADD COLUMN case_type TEXT NOT NULL DEFAULT 'article'; +CREATE TABLE IF NOT EXISTS scoring_runs_next ( + id TEXT PRIMARY KEY, + result_version_id TEXT, + case_type TEXT NOT NULL DEFAULT 'article', + job_id TEXT, + revision INTEGER, + 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 (result_version_id) + REFERENCES optimization_result_versions(id) ON DELETE CASCADE, + FOREIGN KEY (rubric_version_id) REFERENCES rubric_versions(id) +); + +INSERT INTO scoring_runs_next ( + id, result_version_id, case_type, job_id, revision, rubric_version_id, + dimension_scores, composite_score, rationale, created_at +) +SELECT + id, NULL, 'article', job_id, revision, rubric_version_id, + dimension_scores, composite_score, rationale, created_at +FROM scoring_runs; + +DROP TABLE scoring_runs; +ALTER TABLE scoring_runs_next RENAME TO scoring_runs; CREATE INDEX IF NOT EXISTS idx_scoring_runs_result_version ON scoring_runs(result_version_id); + +CREATE INDEX IF NOT EXISTS idx_scoring_runs_job_revision + ON scoring_runs(job_id, revision); diff --git a/src/app/api/__tests__/cases.test.ts b/src/app/api/__tests__/cases.test.ts index 0b9f13c..ae895f5 100644 --- a/src/app/api/__tests__/cases.test.ts +++ b/src/app/api/__tests__/cases.test.ts @@ -19,6 +19,10 @@ vi.mock("../../../lib/llm/client", async () => { }); import { createSqliteRepository } from "../../../lib/db/sqlite-repository"; +import { + HUMAN_COPY_RUBRIC_V1, + scoreHumanCopyResult, +} from "../../../lib/calibration/scoring"; import { GET as listCases } from "../cases/route"; import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route"; import { POST as archiveCase } from "../cases/[caseId]/archive/route"; @@ -206,6 +210,68 @@ describe("case APIs", () => { expect(body.calibrationEvent).toBeNull(); }); + it("creates calibration events for scored human-copy result-version performance", async () => { + const repository = createSqliteRepository(); + const created = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:私域", + summary: "原文", + publish_target: "私域", + source_excerpt: "原文", + }); + const result = { + optimized_text: "优化后文案", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }; + const version = await repository.createOptimizationResultVersion({ + case_id: created.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "优化后文案", + payload: result, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1); + await repository.saveScoringRun( + scoreHumanCopyResult({ + resultVersionId: version.id, + result, + }), + ); + const publication = await repository.createPublicationRecord({ + result_version_id: version.id, + job_id: null, + revision: null, + publish_target: "私域", + url: "https://example.com/private", + published_at: "2026-07-08T12:00:00.000Z", + status: "published", + notes: "", + }); + + const response = await recordPerformance( + request({ + window_label: "T+7d", + views: "1200", + feedback_summary: "客户反馈更自然", + }), + params({ publicationId: publication.id }), + ); + const body = (await response.json()) as { + calibrationEvent: { observations: string[] } | null; + }; + + expect(response.status).toBe(201); + expect(body.calibrationEvent?.observations.join(" ")).toContain("综合评分"); + }); + it("reruns an article case into a new result version and article job", async () => { const repository = createSqliteRepository(); const created = await repository.createOptimizationCase({ @@ -333,13 +399,19 @@ describe("case APIs", () => { const response = await rerunCase(request({}), params({ caseId: created.id })); const body = (await response.json()) as { - result_version: { version: number }; + result_version: { id: string; version: number }; result: { optimized_text: string }; }; expect(response.status).toBe(201); expect(body.result_version.version).toBe(2); expect(body.result.optimized_text).toBe("第二版"); + await expect( + repository.getLatestScoringRunForResultVersion(body.result_version.id), + ).resolves.toMatchObject({ + case_type: "human_copy", + rubric_version_id: HUMAN_COPY_RUBRIC_V1.id, + }); }); }); diff --git a/src/app/api/cases/[caseId]/rerun/route.ts b/src/app/api/cases/[caseId]/rerun/route.ts index 7d5b135..2b4f15b 100644 --- a/src/app/api/cases/[caseId]/rerun/route.ts +++ b/src/app/api/cases/[caseId]/rerun/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireApiAccess } from "../../../../../lib/api/auth"; +import { + HUMAN_COPY_RUBRIC_V1, + scoreHumanCopyResult, +} from "../../../../../lib/calibration/scoring"; import type { ArticleCaseInputPayload, OptimizationCaseDetail, @@ -92,6 +96,13 @@ async function rerunHumanCopyCase( error_stage: null, error_summary: null, }); + await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1); + await repository.saveScoringRun( + scoreHumanCopyResult({ + resultVersionId: resultVersion.id, + result, + }), + ); return NextResponse.json( { diff --git a/src/app/api/copy/renwei-optimize/route.ts b/src/app/api/copy/renwei-optimize/route.ts index 895107c..d93b0eb 100644 --- a/src/app/api/copy/renwei-optimize/route.ts +++ b/src/app/api/copy/renwei-optimize/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireApiAccess } from "../../../../lib/api/auth"; +import { + HUMAN_COPY_RUBRIC_V1, + scoreHumanCopyResult, +} from "../../../../lib/calibration/scoring"; import { buildHumanCopyCaseSummary, createProcessStep, excerpt } from "../../../../lib/cases/summaries"; import { getRepositoryFromRuntime } from "../../../../lib/db/repository"; import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation"; @@ -59,6 +63,13 @@ export async function POST(request: Request) { error_stage: null, error_summary: null, }); + await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1); + await repository.saveScoringRun( + scoreHumanCopyResult({ + resultVersionId: resultVersion.id, + result, + }), + ); return NextResponse.json({ case: { id: optimizationCase.id, case_type: "human_copy" }, diff --git a/src/app/api/jobs/[jobId]/calibration/score/route.ts b/src/app/api/jobs/[jobId]/calibration/score/route.ts index 033f205..71c1da0 100644 --- a/src/app/api/jobs/[jobId]/calibration/score/route.ts +++ b/src/app/api/jobs/[jobId]/calibration/score/route.ts @@ -29,8 +29,17 @@ export async function POST(request: Request, context: RouteContext) { } await repository.saveRubricVersion(GEO_RUBRIC_V1); + const resultVersion = await repository.findResultVersionForArticleRevision( + jobId, + article.revision ?? 1, + ); const scoringRun = await repository.saveScoringRun( - scoreOptimizedArticle({ jobId, article, qaReport }), + scoreOptimizedArticle({ + jobId, + article, + qaReport, + resultVersionId: resultVersion?.id ?? null, + }), ); return NextResponse.json({ scoringRun }, { status: 201 }); diff --git a/src/app/api/publications/[publicationId]/performance/route.ts b/src/app/api/publications/[publicationId]/performance/route.ts index 0a763ce..57a6354 100644 --- a/src/app/api/publications/[publicationId]/performance/route.ts +++ b/src/app/api/publications/[publicationId]/performance/route.ts @@ -4,6 +4,7 @@ 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"; +import type { QaReport } from "../../../../../lib/domain/types"; interface RouteContext { params: Promise<{ publicationId: string }>; @@ -32,9 +33,14 @@ export async function POST(request: Request, context: RouteContext) { publication.revision, ) : null; - const qaReport = publication.job_id + const qaReport: QaReport | null = publication.job_id ? await repository.getLatestQaReport(publication.job_id) - : null; + : scoringRun?.case_type === "human_copy" + ? { + overall_status: "pass", + checks: [], + } + : null; const manualInput = await request.json(); if (!publication.result_version_id && (!scoringRun || !qaReport)) { diff --git a/src/lib/calibration/__tests__/scoring.test.ts b/src/lib/calibration/__tests__/scoring.test.ts index 40359b1..a3f145f 100644 --- a/src/lib/calibration/__tests__/scoring.test.ts +++ b/src/lib/calibration/__tests__/scoring.test.ts @@ -5,6 +5,7 @@ import { createManualPerformanceAdapter } from "../manual-adapter"; import { createCalibrationEvent, GEO_RUBRIC_V1, + scoreHumanCopyResult, scoreOptimizedArticle, } from "../scoring"; @@ -56,8 +57,10 @@ describe("calibration scoring", () => { const snapshot = await adapter.fetch({ publication: { id: "pub_123", + result_version_id: null, job_id: "job_123", revision: 2, + publish_target: "official_site", platform: "official_site", url: "https://example.com/article", published_at: "2026-06-24T12:00:00.000Z", @@ -103,4 +106,39 @@ describe("calibration scoring", () => { expect(event.observations.join(" ")).toContain("询盘"); expect(event.recommended_action).toContain("积累"); }); + + it("scores human-copy result with a separate rubric", () => { + const scoringRun = scoreHumanCopyResult({ + resultVersionId: "ver_1", + result: { + optimized_text: "我把这段文案顺了一下。", + change_notes: [ + { + original: "我把这段文案顺顺。", + revised: "我把这段文案顺了一下。", + reason: "修正重复表达。", + confidence: "confident", + revertible: false, + }, + ], + ai_taste_checks: [ + { + rule_id: "promotion_tone", + status: "pass", + evidence: "没有新增宣传腔。", + suggestion: "", + }, + ], + warnings: [], + }, + }); + + expect(scoringRun).toMatchObject({ + result_version_id: "ver_1", + case_type: "human_copy", + rubric_version_id: "rubric_human_copy_v1", + composite_score: expect.any(Number), + }); + expect(scoringRun.composite_score).toBeGreaterThan(0); + }); }); diff --git a/src/lib/calibration/scoring.ts b/src/lib/calibration/scoring.ts index 08ea12a..b52bb60 100644 --- a/src/lib/calibration/scoring.ts +++ b/src/lib/calibration/scoring.ts @@ -1,6 +1,10 @@ import { nanoid } from "nanoid"; -import type { OptimizedArticle, QaReport } from "../domain/types"; +import type { + CopyOptimizationResult, + OptimizedArticle, + QaReport, +} from "../domain/types"; import type { CalibrationContext, CalibrationDirection, @@ -56,16 +60,59 @@ export const GEO_RUBRIC_V1: RubricVersion = { ], }; +export const HUMAN_COPY_RUBRIC_V1: RubricVersion = { + id: "rubric_human_copy_v1", + version: "v1", + name: "人味文案优化评分口径", + dimensions: [ + { + id: "restraint", + label: "改动克制", + weight: 0.2, + description: "减少机械润色,不把短文案扩写成宣传稿。", + }, + { + id: "meaning_fidelity", + label: "原意保真", + weight: 0.25, + description: "保留原文意图、事实和表达边界。", + }, + { + id: "natural_tone", + label: "语气自然度", + weight: 0.25, + description: "读起来像真人表达,少套路句和格式痕迹。", + }, + { + id: "goal_fit", + label: "目标匹配", + weight: 0.15, + description: "符合优化目标和发布场景。", + }, + { + id: "ai_taste_risk", + label: "AI味风险", + weight: 0.15, + description: "宣传腔、套话、聊天痕迹和填充词风险低。", + }, + ], + formula: "weighted_average_0_to_10", + is_active: true, + created_at: "2026-07-08T00:00:00.000Z", +}; + interface ScoreOptimizedArticleInput { jobId: string; article: OptimizedArticle; qaReport: QaReport; + resultVersionId?: string | null; } export function scoreOptimizedArticle({ jobId, article, qaReport, + resultVersionId = null, }: ScoreOptimizedArticleInput): ScoringRun { const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`; const dimensionScores = { @@ -79,6 +126,8 @@ export function scoreOptimizedArticle({ return { id: `score_${nanoid(10)}`, + result_version_id: resultVersionId, + case_type: "article", job_id: jobId, revision: article.revision ?? 1, rubric_version_id: GEO_RUBRIC_V1.id, @@ -89,6 +138,43 @@ export function scoreOptimizedArticle({ }; } +export function scoreHumanCopyResult({ + resultVersionId, + result, +}: { + resultVersionId: string; + result: CopyOptimizationResult; +}): ScoringRun { + const warningsPenalty = Math.min(result.warnings.length, 3) * 0.5; + const aiTasteWarnings = result.ai_taste_checks.filter( + (check) => check.status === "warn", + ).length; + const dimensionScores = { + restraint: result.optimized_text.length > 280 ? 3 : 5, + meaning_fidelity: result.change_notes.some( + (note) => note.confidence === "uncertain", + ) + ? 3.5 + : 5, + natural_tone: Math.max(2, 5 - aiTasteWarnings * 0.75), + goal_fit: 4.5, + ai_taste_risk: Math.max(1, 5 - aiTasteWarnings - warningsPenalty), + }; + + return { + id: `score_${nanoid(10)}`, + result_version_id: resultVersionId, + case_type: "human_copy", + job_id: null, + revision: null, + rubric_version_id: HUMAN_COPY_RUBRIC_V1.id, + dimension_scores: dimensionScores, + composite_score: weightedAverage0To10(HUMAN_COPY_RUBRIC_V1, dimensionScores), + rationale: "基于改动克制、原意保真、自然度、目标匹配和AI味风险生成评分。", + created_at: new Date().toISOString(), + }; +} + function scoreFactIntegrity(report: QaReport) { const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"]; const statuses = report.checks @@ -143,11 +229,15 @@ function scoreReadability(article: OptimizedArticle) { } function weightedComposite(scores: Record) { - const totalWeight = GEO_RUBRIC_V1.dimensions.reduce( + return weightedAverage0To10(GEO_RUBRIC_V1, scores); +} + +function weightedAverage0To10(rubric: RubricVersion, scores: Record) { + const totalWeight = rubric.dimensions.reduce( (sum, dimension) => sum + dimension.weight, 0, ); - const weighted = GEO_RUBRIC_V1.dimensions.reduce( + const weighted = rubric.dimensions.reduce( (sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight, 0, ); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 107f431..565fd4b 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -207,8 +207,7 @@ export function initializeSchema(db: Database.Database) { `); ensureColumn(db, "article_jobs", "case_id", "text"); - ensureColumn(db, "scoring_runs", "result_version_id", "text"); - ensureColumn(db, "scoring_runs", "case_type", "text not null default 'article'"); + migrateScoringRunsForCases(db); ensureColumn(db, "publication_records", "result_version_id", "text"); ensureColumn( db, @@ -234,10 +233,72 @@ export function initializeSchema(db: Database.Database) { } function getTableColumns(db: Database.Database, tableName: string) { + return getTableColumnInfo(db, tableName).map((row) => row.name); +} + +function getTableColumnInfo(db: Database.Database, tableName: string) { return db .prepare(`pragma table_info(${tableName})`) .all() - .map((row) => (row as { name: string }).name); + .map((row) => row as { name: string; notnull: number }); +} + +function migrateScoringRunsForCases(db: Database.Database) { + const columns = getTableColumnInfo(db, "scoring_runs"); + const columnNames = columns.map((column) => column.name); + const jobIdColumn = columns.find((column) => column.name === "job_id"); + const revisionColumn = columns.find((column) => column.name === "revision"); + const needsRebuild = + !columnNames.includes("result_version_id") || + !columnNames.includes("case_type") || + jobIdColumn?.notnull === 1 || + revisionColumn?.notnull === 1; + + if (!needsRebuild) return; + + const resultVersionSelect = columnNames.includes("result_version_id") + ? "result_version_id" + : "NULL"; + const caseTypeSelect = columnNames.includes("case_type") + ? "case_type" + : "'article'"; + + db.pragma("foreign_keys = OFF"); + try { + db.exec(` + drop table if exists scoring_runs_next; + + create table scoring_runs_next ( + id text primary key, + result_version_id text, + case_type text not null default 'article', + job_id text, + revision integer, + 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 (result_version_id) + references optimization_result_versions(id) on delete cascade, + foreign key (rubric_version_id) references rubric_versions(id) + ); + + insert into scoring_runs_next ( + id, result_version_id, case_type, job_id, revision, rubric_version_id, + dimension_scores, composite_score, rationale, created_at + ) + select + id, ${resultVersionSelect}, ${caseTypeSelect}, job_id, revision, + rubric_version_id, dimension_scores, composite_score, rationale, created_at + from scoring_runs; + + drop table scoring_runs; + alter table scoring_runs_next rename to scoring_runs; + `); + } finally { + db.pragma("foreign_keys = ON"); + } } function ensureColumn(