scripts/eval-story-digests-core.ts
Rama main · réplica de lectura del repositorio original.
// Story-digest evaluation cases, one event per JSONL line, written by hand or exported from the database
// (scripts/eval-story-digests.ts --stories); the site's digest input made from a case; and the comparison
// a run writes, as Markdown for reading.
import { z } from "zod";
import { byReportTime } from "@aihot/backend/events/digest";
import type { DigestReport } from "@aihot/backend/publication/story-evidence";
import { parseCasesJsonl } from "./eval-tools.ts";
const ReportSchema = z.strictObject({
id: z.string().min(1),
publishedAt: z.iso.datetime({ offset: true }),
source: z.string().min(1),
firstParty: z.boolean().default(false),
title: z.string().min(1),
summary: z.string().nullable().default(null),
fact: z.strictObject({
id: z.number().int().positive(),
subject: z.string().nullable().default(null),
action: z.string().nullable().default(null),
object: z.string().nullable().default(null),
conditions: z.string().nullable().default(null),
evidence: z.string().nullable().default(null),
/** The analysis's structured fact: its conditions with source quotes, and its evidence. */
structured: z.unknown().default(null),
}),
});
const CaseSchema = z.strictObject({
caseId: z.string().min(1),
story: z.strictObject({ title: z.string().min(1), previousDigest: z.string().nullable().default(null) }),
/**
* incremental: the site's update, with previousDigest (if any) and the reports not in knownArticleIds
* marked new; with neither, an event's first digest. corrected: rewritten from the reports as they are
* now, without the previous digest, as after a correction.
*/
inputMode: z.enum(["incremental", "corrected"]).default("incremental"),
knownArticleIds: z.array(z.string().min(1)).default([]),
reports: z.array(ReportSchema).min(1),
});
export type DigestEvalCase = z.infer<typeof CaseSchema>;
export const parseDigestEvalJsonl = (text: string): DigestEvalCase[] => parseCasesJsonl(text, CaseSchema);
/**
* An event as the site rewrites its digest from its current reports (digestReports): without the
* previous digest when it has saved one, the way a correction is rewritten; as a first digest otherwise.
*/
export function caseFromStory(story: { publicId: string; title: string; hasDigest: boolean }, reports: DigestReport[]): DigestEvalCase {
return {
caseId: story.publicId,
story: { title: story.title, previousDigest: null },
inputMode: story.hasDigest ? "corrected" : "incremental",
knownArticleIds: [],
reports: [...reports].sort(byReportTime).map((report) => ({
id: report.id,
publishedAt: report.at.toISOString(),
source: report.source_name,
firstParty: report.first_party,
title: report.title,
summary: report.summary,
fact: {
id: report.fact_id,
subject: report.fact_subject,
action: report.fact_action,
object: report.fact_object,
conditions: report.fact_conditions,
evidence: report.evidence,
structured: report.structured_fact ?? null,
},
})),
};
}
/** The case as buildStoryDigestInput's arguments. */
export function toDigestInput(row: DigestEvalCase) {
return {
story: { title: row.story.title, digest: row.story.previousDigest },
reports: row.reports.map((report): Omit<DigestReport, "story_id"> => ({
id: report.id,
title: report.title,
summary: report.summary,
source_name: report.source,
first_party: report.firstParty,
at: new Date(report.publishedAt),
fact_id: report.fact.id,
fact_subject: report.fact.subject,
fact_action: report.fact.action,
fact_object: report.fact.object,
fact_conditions: report.fact.conditions,
evidence: report.fact.evidence,
structured_fact: report.fact.structured,
})).sort(byReportTime),
corrected: row.inputMode === "corrected",
knownArticleIds: row.knownArticleIds,
};
}
export function inputForm(row: DigestEvalCase): string {
if (row.inputMode === "corrected") return "更正后重写(不带上一版综述)";
return row.story.previousDigest ? "增量更新(带上一版综述)" : "首次撰写";
}
export interface DigestCall {
title: string | null;
digest: string | null;
receiptId: number | null;
reused: boolean;
tokensIn: number;
tokensOut: number;
avgLatencyMs: number;
error: string | null;
}
export interface DigestPromptSummary {
calls: number;
succeeded: number;
errors: number;
reused: number;
tokensIn: number;
tokensOut: number;
avgLatencyMs: number;
}
/** What a run writes: per model, each case's call with the site's prompt beside the candidate's. */
export interface DigestEvalReport {
meta: {
createdAt: string;
cases: string;
calls: number;
prompts: { live: { version: string }; candidate?: { file: string; version: string } };
};
models: Record<string, {
summary: { live: DigestPromptSummary; candidate?: DigestPromptSummary; wallSeconds: number };
cases: Array<{ caseId: string; storyTitle: string; input: string; reports: number; live: DigestCall; candidate?: DigestCall }>;
}>;
}
const PROMPTS = [["live", "线上提示词"], ["candidate", "候选提示词"]] as const;
const usage = (call: { receiptId: number | null; reused: boolean; tokensIn: number; tokensOut: number }) =>
`${call.receiptId === null ? "无回执" : `回执 #${call.receiptId}`} · 输入 ${call.tokensIn} tokens · 输出 ${call.tokensOut} tokens${call.reused ? " · 复用已收到的结果" : ""}`;
/** The report for reading: every case's answers one after another, line breaks kept. */
export function digestComparisonMarkdown(report: DigestEvalReport): string {
const { meta } = report;
const lines = [
"# 事件综述提示词对比",
"",
`- 生成时间:${meta.createdAt}`,
`- 案例:${meta.cases}`,
`- 线上提示词:${meta.prompts.live.version}`,
...(meta.prompts.candidate ? [`- 候选提示词:${meta.prompts.candidate.file}(${meta.prompts.candidate.version})`] : []),
`- 模型调用:${meta.calls} 次`,
];
for (const [model, run] of Object.entries(report.models)) {
for (const [key, label] of PROMPTS) {
const summary = run.summary[key];
if (summary) lines.push(`- ${model} · ${label}:成功 ${summary.succeeded}/${summary.calls},输入 ${summary.tokensIn} tokens,输出 ${summary.tokensOut} tokens,${summary.reused} 次复用已收到的结果`);
}
}
const runs = Object.entries(report.models);
for (const [index, item] of (runs[0]?.[1].cases ?? []).entries()) {
lines.push("", `## ${index + 1}. ${item.storyTitle}`, "", `案例 \`${item.caseId}\` · 报道 ${item.reports} 篇 · ${item.input}`);
for (const [model, run] of runs) {
for (const [key, label] of PROMPTS) {
const call = run.cases[index]![key];
if (!call) continue;
lines.push("", `### ${label} · ${model}`, "");
if (call.error !== null || call.digest === null) lines.push(`生成失败:${call.error}`);
else lines.push(`**${call.title || "(沿用事件标题)"}**`, "", ...call.digest.split("\n").map((line) => (line ? `> ${line} ` : ">")));
lines.push("", usage(call));
}
}
}
return `${lines.join("\n")}\n`;
}