新增人味文案评分和效果学习
This commit is contained in:
@@ -99,8 +99,36 @@ CREATE INDEX IF NOT EXISTS idx_publication_records_result_version
|
|||||||
CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision
|
CREATE INDEX IF NOT EXISTS idx_publication_records_job_revision
|
||||||
ON publication_records(job_id, revision);
|
ON publication_records(job_id, revision);
|
||||||
|
|
||||||
ALTER TABLE scoring_runs ADD COLUMN result_version_id TEXT;
|
CREATE TABLE IF NOT EXISTS scoring_runs_next (
|
||||||
ALTER TABLE scoring_runs ADD COLUMN case_type TEXT NOT NULL DEFAULT 'article';
|
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
|
CREATE INDEX IF NOT EXISTS idx_scoring_runs_result_version
|
||||||
ON scoring_runs(result_version_id);
|
ON scoring_runs(result_version_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scoring_runs_job_revision
|
||||||
|
ON scoring_runs(job_id, revision);
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ vi.mock("../../../lib/llm/client", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { createSqliteRepository } from "../../../lib/db/sqlite-repository";
|
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 listCases } from "../cases/route";
|
||||||
import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route";
|
import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route";
|
||||||
import { POST as archiveCase } from "../cases/[caseId]/archive/route";
|
import { POST as archiveCase } from "../cases/[caseId]/archive/route";
|
||||||
@@ -206,6 +210,68 @@ describe("case APIs", () => {
|
|||||||
expect(body.calibrationEvent).toBeNull();
|
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 () => {
|
it("reruns an article case into a new result version and article job", async () => {
|
||||||
const repository = createSqliteRepository();
|
const repository = createSqliteRepository();
|
||||||
const created = await repository.createOptimizationCase({
|
const created = await repository.createOptimizationCase({
|
||||||
@@ -333,13 +399,19 @@ describe("case APIs", () => {
|
|||||||
|
|
||||||
const response = await rerunCase(request({}), params({ caseId: created.id }));
|
const response = await rerunCase(request({}), params({ caseId: created.id }));
|
||||||
const body = (await response.json()) as {
|
const body = (await response.json()) as {
|
||||||
result_version: { version: number };
|
result_version: { id: string; version: number };
|
||||||
result: { optimized_text: string };
|
result: { optimized_text: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(body.result_version.version).toBe(2);
|
expect(body.result_version.version).toBe(2);
|
||||||
expect(body.result.optimized_text).toBe("第二版");
|
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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireApiAccess } from "../../../../../lib/api/auth";
|
import { requireApiAccess } from "../../../../../lib/api/auth";
|
||||||
|
import {
|
||||||
|
HUMAN_COPY_RUBRIC_V1,
|
||||||
|
scoreHumanCopyResult,
|
||||||
|
} from "../../../../../lib/calibration/scoring";
|
||||||
import type {
|
import type {
|
||||||
ArticleCaseInputPayload,
|
ArticleCaseInputPayload,
|
||||||
OptimizationCaseDetail,
|
OptimizationCaseDetail,
|
||||||
@@ -92,6 +96,13 @@ async function rerunHumanCopyCase(
|
|||||||
error_stage: null,
|
error_stage: null,
|
||||||
error_summary: null,
|
error_summary: null,
|
||||||
});
|
});
|
||||||
|
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
|
||||||
|
await repository.saveScoringRun(
|
||||||
|
scoreHumanCopyResult({
|
||||||
|
resultVersionId: resultVersion.id,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { requireApiAccess } from "../../../../lib/api/auth";
|
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 { buildHumanCopyCaseSummary, createProcessStep, excerpt } from "../../../../lib/cases/summaries";
|
||||||
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
|
import { getRepositoryFromRuntime } from "../../../../lib/db/repository";
|
||||||
import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation";
|
import { copyOptimizationRequestSchema } from "../../../../lib/domain/validation";
|
||||||
@@ -59,6 +63,13 @@ export async function POST(request: Request) {
|
|||||||
error_stage: null,
|
error_stage: null,
|
||||||
error_summary: null,
|
error_summary: null,
|
||||||
});
|
});
|
||||||
|
await repository.saveRubricVersion(HUMAN_COPY_RUBRIC_V1);
|
||||||
|
await repository.saveScoringRun(
|
||||||
|
scoreHumanCopyResult({
|
||||||
|
resultVersionId: resultVersion.id,
|
||||||
|
result,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
case: { id: optimizationCase.id, case_type: "human_copy" },
|
case: { id: optimizationCase.id, case_type: "human_copy" },
|
||||||
|
|||||||
@@ -29,8 +29,17 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await repository.saveRubricVersion(GEO_RUBRIC_V1);
|
await repository.saveRubricVersion(GEO_RUBRIC_V1);
|
||||||
|
const resultVersion = await repository.findResultVersionForArticleRevision(
|
||||||
|
jobId,
|
||||||
|
article.revision ?? 1,
|
||||||
|
);
|
||||||
const scoringRun = await repository.saveScoringRun(
|
const scoringRun = await repository.saveScoringRun(
|
||||||
scoreOptimizedArticle({ jobId, article, qaReport }),
|
scoreOptimizedArticle({
|
||||||
|
jobId,
|
||||||
|
article,
|
||||||
|
qaReport,
|
||||||
|
resultVersionId: resultVersion?.id ?? null,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ scoringRun }, { status: 201 });
|
return NextResponse.json({ scoringRun }, { status: 201 });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requireApiAccess } from "../../../../../lib/api/auth";
|
|||||||
import { createManualPerformanceAdapter } from "../../../../../lib/calibration/manual-adapter";
|
import { createManualPerformanceAdapter } from "../../../../../lib/calibration/manual-adapter";
|
||||||
import { createCalibrationEvent } from "../../../../../lib/calibration/scoring";
|
import { createCalibrationEvent } from "../../../../../lib/calibration/scoring";
|
||||||
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
|
||||||
|
import type { QaReport } from "../../../../../lib/domain/types";
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ publicationId: string }>;
|
params: Promise<{ publicationId: string }>;
|
||||||
@@ -32,8 +33,13 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
publication.revision,
|
publication.revision,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const qaReport = publication.job_id
|
const qaReport: QaReport | null = publication.job_id
|
||||||
? await repository.getLatestQaReport(publication.job_id)
|
? await repository.getLatestQaReport(publication.job_id)
|
||||||
|
: scoringRun?.case_type === "human_copy"
|
||||||
|
? {
|
||||||
|
overall_status: "pass",
|
||||||
|
checks: [],
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
const manualInput = await request.json();
|
const manualInput = await request.json();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createManualPerformanceAdapter } from "../manual-adapter";
|
|||||||
import {
|
import {
|
||||||
createCalibrationEvent,
|
createCalibrationEvent,
|
||||||
GEO_RUBRIC_V1,
|
GEO_RUBRIC_V1,
|
||||||
|
scoreHumanCopyResult,
|
||||||
scoreOptimizedArticle,
|
scoreOptimizedArticle,
|
||||||
} from "../scoring";
|
} from "../scoring";
|
||||||
|
|
||||||
@@ -56,8 +57,10 @@ describe("calibration scoring", () => {
|
|||||||
const snapshot = await adapter.fetch({
|
const snapshot = await adapter.fetch({
|
||||||
publication: {
|
publication: {
|
||||||
id: "pub_123",
|
id: "pub_123",
|
||||||
|
result_version_id: null,
|
||||||
job_id: "job_123",
|
job_id: "job_123",
|
||||||
revision: 2,
|
revision: 2,
|
||||||
|
publish_target: "official_site",
|
||||||
platform: "official_site",
|
platform: "official_site",
|
||||||
url: "https://example.com/article",
|
url: "https://example.com/article",
|
||||||
published_at: "2026-06-24T12:00:00.000Z",
|
published_at: "2026-06-24T12:00:00.000Z",
|
||||||
@@ -103,4 +106,39 @@ describe("calibration scoring", () => {
|
|||||||
expect(event.observations.join(" ")).toContain("询盘");
|
expect(event.observations.join(" ")).toContain("询盘");
|
||||||
expect(event.recommended_action).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
import type { OptimizedArticle, QaReport } from "../domain/types";
|
import type {
|
||||||
|
CopyOptimizationResult,
|
||||||
|
OptimizedArticle,
|
||||||
|
QaReport,
|
||||||
|
} from "../domain/types";
|
||||||
import type {
|
import type {
|
||||||
CalibrationContext,
|
CalibrationContext,
|
||||||
CalibrationDirection,
|
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 {
|
interface ScoreOptimizedArticleInput {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
article: OptimizedArticle;
|
article: OptimizedArticle;
|
||||||
qaReport: QaReport;
|
qaReport: QaReport;
|
||||||
|
resultVersionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scoreOptimizedArticle({
|
export function scoreOptimizedArticle({
|
||||||
jobId,
|
jobId,
|
||||||
article,
|
article,
|
||||||
qaReport,
|
qaReport,
|
||||||
|
resultVersionId = null,
|
||||||
}: ScoreOptimizedArticleInput): ScoringRun {
|
}: ScoreOptimizedArticleInput): ScoringRun {
|
||||||
const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
|
const combined = `${article.title}\n${article.summary}\n${article.body_markdown}`;
|
||||||
const dimensionScores = {
|
const dimensionScores = {
|
||||||
@@ -79,6 +126,8 @@ export function scoreOptimizedArticle({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `score_${nanoid(10)}`,
|
id: `score_${nanoid(10)}`,
|
||||||
|
result_version_id: resultVersionId,
|
||||||
|
case_type: "article",
|
||||||
job_id: jobId,
|
job_id: jobId,
|
||||||
revision: article.revision ?? 1,
|
revision: article.revision ?? 1,
|
||||||
rubric_version_id: GEO_RUBRIC_V1.id,
|
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) {
|
function scoreFactIntegrity(report: QaReport) {
|
||||||
const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"];
|
const hardRules = ["company_name_integrity", "claim_consistency", "hallucination_risk"];
|
||||||
const statuses = report.checks
|
const statuses = report.checks
|
||||||
@@ -143,11 +229,15 @@ function scoreReadability(article: OptimizedArticle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function weightedComposite(scores: Record<string, number>) {
|
function weightedComposite(scores: Record<string, number>) {
|
||||||
const totalWeight = GEO_RUBRIC_V1.dimensions.reduce(
|
return weightedAverage0To10(GEO_RUBRIC_V1, scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedAverage0To10(rubric: RubricVersion, scores: Record<string, number>) {
|
||||||
|
const totalWeight = rubric.dimensions.reduce(
|
||||||
(sum, dimension) => sum + dimension.weight,
|
(sum, dimension) => sum + dimension.weight,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const weighted = GEO_RUBRIC_V1.dimensions.reduce(
|
const weighted = rubric.dimensions.reduce(
|
||||||
(sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight,
|
(sum, dimension) => sum + (scores[dimension.id] ?? 0) * dimension.weight,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|||||||
+64
-3
@@ -207,8 +207,7 @@ export function initializeSchema(db: Database.Database) {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
ensureColumn(db, "article_jobs", "case_id", "text");
|
ensureColumn(db, "article_jobs", "case_id", "text");
|
||||||
ensureColumn(db, "scoring_runs", "result_version_id", "text");
|
migrateScoringRunsForCases(db);
|
||||||
ensureColumn(db, "scoring_runs", "case_type", "text not null default 'article'");
|
|
||||||
ensureColumn(db, "publication_records", "result_version_id", "text");
|
ensureColumn(db, "publication_records", "result_version_id", "text");
|
||||||
ensureColumn(
|
ensureColumn(
|
||||||
db,
|
db,
|
||||||
@@ -234,10 +233,72 @@ export function initializeSchema(db: Database.Database) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTableColumns(db: Database.Database, tableName: string) {
|
function getTableColumns(db: Database.Database, tableName: string) {
|
||||||
|
return getTableColumnInfo(db, tableName).map((row) => row.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTableColumnInfo(db: Database.Database, tableName: string) {
|
||||||
return db
|
return db
|
||||||
.prepare(`pragma table_info(${tableName})`)
|
.prepare(`pragma table_info(${tableName})`)
|
||||||
.all()
|
.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(
|
function ensureColumn(
|
||||||
|
|||||||
Reference in New Issue
Block a user