545 lines
24 KiB
TypeScript
545 lines
24 KiB
TypeScript
import { Agent } from "@mastra/core/agent";
|
||
import { z } from "zod";
|
||
import type { ResolvedLanguageModel } from "./model";
|
||
import type { PersonalReportSectionPlan, ReportSectionPlanEntry } from "@/lib/personal-report-plan";
|
||
import { cachedSystemMessage, mergePromptCacheUsage, promptCacheUsage, agentGenerationSettings } from "@/lib/agent-generation-settings";
|
||
import { cachedInterpretationPreamble, interpretationPackForTheme } from "@/lib/report-interpretation-packs";
|
||
import type {
|
||
ReportChartHouse,
|
||
ReportDashaPeriod,
|
||
ReportEvidenceBundleV2,
|
||
ReportEvidenceRef,
|
||
ReportPlanetFact,
|
||
} from "@/lib/report-evidence-bundle-v2";
|
||
export type {
|
||
ClaimStatus,
|
||
EvidenceRefStatus,
|
||
ReportChartHouse,
|
||
ReportDashaPeriod,
|
||
ReportEvidenceBundleV2,
|
||
ReportEvidenceRef,
|
||
ReportPlanetFact,
|
||
} from "@/lib/report-evidence-bundle-v2";
|
||
|
||
/**
|
||
* Personal Report Agent — dedicated report writer, deliberately separate from
|
||
* the chat agent. It has NO skills, NO tools and NO memory: the model receives
|
||
* only the allowlisted facts inside `ReportEvidenceBundleV2`. Chat history,
|
||
* SKILL.md source text, system prompts, tool traces, internal paths and error
|
||
* stacks must never reach this agent.
|
||
*/
|
||
|
||
export const claimStatusSchema = z.enum([
|
||
"multi_system_consensus",
|
||
"single_system_inference",
|
||
"parameter_sensitive",
|
||
"unclosed_divisional_chart",
|
||
"user_history_verification_required",
|
||
"blocked",
|
||
]);
|
||
|
||
export type ReportVargaHouses = Readonly<{
|
||
id: "D2" | "D6" | "D8" | "D9" | "D10" | "D11" | "D24" | "D30";
|
||
houses: readonly ReportChartHouse[];
|
||
}>;
|
||
|
||
export type ReportEvidencePacket = Readonly<{
|
||
schemaVersion: "report_evidence_packet.v1";
|
||
subject: Readonly<{
|
||
displayName: string;
|
||
birthTimeStatus: "reported" | "candidate" | "accepted" | "confirmed";
|
||
birthPlaceLabel: string;
|
||
}>;
|
||
requestedThemes: readonly string[];
|
||
reportType: "personal_full" | "personal_thematic";
|
||
presentationMode: "default" | "research";
|
||
chart: Readonly<{
|
||
/** Canonical 64-hex calculation hash; derived server-side when absent. */
|
||
calculationHash: string;
|
||
/** True when the hash was derived from allowlisted facts, not the engine. */
|
||
calculationHashDerived: boolean;
|
||
ascendant: Readonly<{ sign: string; degree: number }> | null;
|
||
planets: readonly ReportPlanetFact[];
|
||
/** D1 houses; must cover 1..12 for a usable report. */
|
||
houses: readonly ReportChartHouse[];
|
||
vimshottari: readonly ReportDashaPeriod[] | null;
|
||
narayana: readonly ReportDashaPeriod[] | null;
|
||
/** Real divisional houses only; empty arrays mean the chart is omitted. */
|
||
vargaHouses: readonly ReportVargaHouses[];
|
||
}>;
|
||
techniqueAudit: readonly Readonly<{
|
||
id?: string;
|
||
technique: string;
|
||
status: string;
|
||
note: string;
|
||
}>[];
|
||
conflicts: readonly Readonly<{
|
||
id?: string;
|
||
techniques: readonly string[];
|
||
summary: string;
|
||
}>[];
|
||
blockedTechniques: readonly string[];
|
||
/** Canonical appendix ids the agent may cite (ev-audit/ev-conflict/ev-calc). */
|
||
evidenceRefs: readonly ReportEvidenceRef[];
|
||
candidateRange: Readonly<{ start: string; end: string }> | null;
|
||
answerPolicy: Readonly<{
|
||
canAnswerPreciseTiming: boolean;
|
||
deterministicClaimsForbiddenFor: readonly string[];
|
||
}>;
|
||
skillName: string;
|
||
skillVersion: string;
|
||
skillSnapshotSha256: string;
|
||
skillSourceCommit: string | null;
|
||
}>;
|
||
|
||
const reportSectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id");
|
||
const evidenceRefIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
|
||
|
||
export type PersonalReportExecutiveSummary = Readonly<{
|
||
headline: string;
|
||
summary: string;
|
||
priorities: string[];
|
||
}>;
|
||
|
||
export type PersonalReportThematicNarrative = Readonly<{
|
||
id: string;
|
||
theme: string;
|
||
title: string;
|
||
narrative: string;
|
||
actions: string[];
|
||
caveats: string[];
|
||
claimStatus: z.infer<typeof claimStatusSchema>;
|
||
evidenceRefs: string[];
|
||
}>;
|
||
|
||
export const personalReportExecutiveSummarySchema = z.object({
|
||
headline: z.string().trim().min(1).max(200),
|
||
summary: z.string().trim().min(1).max(2000),
|
||
priorities: z.array(z.string().trim().min(1).max(200)).max(8).default([]),
|
||
}).strict() as unknown as z.ZodType<PersonalReportExecutiveSummary>;
|
||
|
||
export const personalReportThematicNarrativeSchema = z.object({
|
||
id: reportSectionIdSchema,
|
||
theme: z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id"),
|
||
title: z.string().trim().min(1).max(160),
|
||
narrative: z.string().trim().min(1).max(4000),
|
||
actions: z.array(z.string().trim().min(1).max(400)).max(12).default([]),
|
||
caveats: z.array(z.string().trim().min(1).max(400)).max(12).default([]),
|
||
claimStatus: claimStatusSchema,
|
||
evidenceRefs: z.array(evidenceRefIdSchema).min(1).max(24),
|
||
}).strict() as unknown as z.ZodType<PersonalReportThematicNarrative>;
|
||
|
||
export const personalReportAgentOutputSchema = z.object({
|
||
executiveSummary: personalReportExecutiveSummarySchema,
|
||
thematicNarrative: z.array(personalReportThematicNarrativeSchema).max(12),
|
||
}).strict() as unknown as z.ZodType<PersonalReportAgentOutput>;
|
||
|
||
export type PersonalReportAgentOutput = Readonly<{
|
||
executiveSummary: PersonalReportExecutiveSummary;
|
||
thematicNarrative: PersonalReportThematicNarrative[];
|
||
}>;
|
||
|
||
export type PersonalReportAgentTelemetry = Readonly<{
|
||
modelId: string;
|
||
outcome: "resolved" | "aborted" | "failed";
|
||
elapsedMs: number;
|
||
inputTokens: number | null;
|
||
outputTokens: number | null;
|
||
totalTokens: number | null;
|
||
finishReason: string | null;
|
||
repairAttempted: boolean;
|
||
/** Metric only: how many interpretive rows this call's bundle carried. */
|
||
interpretiveFactCount: number;
|
||
/** Metric only: characters of static knowledge pack sent with this call. */
|
||
knowledgePackCharacters: number;
|
||
}>;
|
||
|
||
/**
|
||
* Counts the interpretive rows in a bundle. Numbers only — never content.
|
||
* Telemetry must never break generation, so a bundle without the block (or a
|
||
* malformed one) counts as zero instead of throwing.
|
||
*/
|
||
export function countInterpretiveFacts(bundle: ReportEvidenceBundleV2): number {
|
||
const facts = bundle?.interpretiveFacts as ReportEvidenceBundleV2["interpretiveFacts"] | undefined;
|
||
if (!facts) return 0;
|
||
const size = (value: unknown) => (Array.isArray(value) ? value.length : 0);
|
||
return size(facts.yogas)
|
||
+ size(facts.functionalRoles)
|
||
+ size(facts.shadbalaRanking)
|
||
+ size(facts.savScores)
|
||
+ size(facts.convergenceDomains)
|
||
+ (facts.currentDasha ? 1 : 0)
|
||
+ (typeof facts.savTotal === "number" ? 1 : 0)
|
||
+ size(facts.planetaryFriendship)
|
||
+ (facts.pratyantarTimeline ? 1 : 0);
|
||
}
|
||
|
||
const personalReportInstructions = `You are the dedicated Personal Report writer for a Vedic astrology product. You write long structured report sections in Simplified Chinese. This is a report, not a chat: do not use chat-style short paragraphs, do not ask follow-up questions, and do not append hidden blocks.
|
||
|
||
The user message contains the ONLY allowed facts: a server-computed ReportEvidenceBundleV2. Use its claimCards exclusively for narrative conclusions. Its interpretiveFacts (yogas, functionalRoles, shadbalaRanking, savScores, currentDasha, convergenceDomains, planetaryFriendship grade table, pratyantarTimeline current/next dates) and themeNarrativeSeeds are the supporting fact layer: you may quote, order, group and explain them, but they never authorise a conclusion that the matching claimCard does not already state. Never invent, recalculate, or infer planetary positions, house lords, dasha boundaries, divisional charts, shadbala/ashtakavarga values, yogas, or timing windows that are not present in those fields. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or methodology details.
|
||
|
||
Truth boundaries are hard output contracts:
|
||
- A blockedSections entry must be disclosed honestly and must not be rewritten as a conclusion. A partial or blocked executionLedger receipt can never be upgraded to verified or consensus. Never raise a claimCard assertionLevel; output the same or a stricter level.
|
||
- If answerPolicy.canAnswerPreciseTiming is false, give direction and structure only: never state a month, a date, a specific year, or a guaranteed timing outcome. accepted birth time remains directional-only unless the policy is confirmed.
|
||
- Do not provide medical, legal, investment or safety-critical advice. Never predict death, diagnosis, pregnancy outcomes, or guaranteed financial/legal outcomes, even as "必定/一定/肯定/保证/必然/百分之百" phrasing.
|
||
- Keep the disclaimer boundary: astrology is interpretive, not deterministic.
|
||
|
||
Structure rules:
|
||
- Produce exactly the JSON object described by the requested output schema. No Markdown fences, no commentary, no hidden fields.
|
||
- executiveSummary.headline is one calm, concise Chinese sentence of at most 200 characters; it must not contain dates, timing windows, or deterministic claims.
|
||
- Write only thematic sections whose plan disposition is write. Section id, theme, and evidenceRefs must exactly match the plan entry. Every narrative conclusion must trace to the matching claimCard. Never output a blocked plan entry; the server creates those disclosures deterministically.
|
||
- Keep actions concrete and cautious; caveats must state limits honestly.
|
||
- Write formal, readable Simplified Chinese for a printed report.
|
||
|
||
The system messages also carry a static interpretation guide (wording, reasoning discipline, forbidden phrasing). It governs HOW you explain a fact and how you word it. It is never a source of facts: it must never produce an astrological statement that the bundle does not already contain, and it can never raise certainty.`;
|
||
|
||
async function readUsage(value: unknown) {
|
||
try {
|
||
const resolved = await Promise.resolve(value);
|
||
const record = resolved !== null && typeof resolved === "object" && !Array.isArray(resolved)
|
||
? resolved as Record<string, unknown>
|
||
: {};
|
||
const numberOrNull = (key: string) => (
|
||
typeof record[key] === "number" && Number.isFinite(record[key]) ? record[key] as number : null
|
||
);
|
||
const cache = promptCacheUsage(record);
|
||
return {
|
||
inputTokens: numberOrNull("inputTokens"),
|
||
outputTokens: numberOrNull("outputTokens"),
|
||
totalTokens: numberOrNull("totalTokens"),
|
||
cache,
|
||
};
|
||
} catch {
|
||
return { inputTokens: null, outputTokens: null, totalTokens: null, cache: null };
|
||
}
|
||
}
|
||
|
||
async function readFinishReason(value: unknown): Promise<string | null> {
|
||
try {
|
||
const resolved = await Promise.resolve(value);
|
||
return typeof resolved === "string" && resolved.length <= 80 ? resolved : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export class PersonalReportAgentOutputError extends Error {
|
||
readonly code = "report_schema_invalid";
|
||
|
||
constructor(readonly finishReason: string | null = null) {
|
||
super("report_schema_invalid");
|
||
this.name = "PersonalReportAgentOutputError";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Builds the only user-message content sent to the model: the serialized
|
||
* minimal evidence packet. Nothing else is appended; chat history and skill
|
||
* text are structurally excluded by the agent definition (no skills, no tools,
|
||
* no memory).
|
||
*/
|
||
export function buildReportPrompt(
|
||
bundle: ReportEvidenceBundleV2,
|
||
plan: PersonalReportSectionPlan,
|
||
): string {
|
||
return `请根据以下唯一的已验证 ReportEvidenceBundleV2 和 PersonalReportSectionPlan 生成个人报告 JSON。只输出 plan 中 disposition=write 的 thematic section;blocked section 由服务器确定性生成,不得补写。只使用 claimCards 中的结论,不得提升 assertionLevel。每个 section 的 id、theme 和 evidenceRefs 必须严格匹配 plan。严格按输出 schema 返回 JSON。
|
||
${JSON.stringify({ bundle, plan })}`;
|
||
}
|
||
|
||
export type ReportAgentGenerateOptions = Readonly<{
|
||
signal?: AbortSignal;
|
||
/** Server-owned plan binding. Failure here consumes the single repair retry. */
|
||
assertWriterOutput?: (output: PersonalReportAgentOutput) => void;
|
||
}>;
|
||
|
||
export type ReportAgentSectionOptions = Readonly<{
|
||
signal?: AbortSignal;
|
||
assertWriterOutput?: (output: z.output<typeof personalReportThematicNarrativeSchema>) => void;
|
||
maxOutputTokens?: number;
|
||
targetCharactersMin?: number;
|
||
}>;
|
||
|
||
export type ReportAgentSummaryOptions = Readonly<{
|
||
signal?: AbortSignal;
|
||
maxOutputTokens?: number;
|
||
}>;
|
||
|
||
export type ReportAgentUsage = Readonly<{ inputTokens: number; outputTokens: number; cache?: ReturnType<typeof promptCacheUsage> }>;
|
||
|
||
export type ReportAgentPort = Readonly<{
|
||
modelId: string;
|
||
getUsage?: () => ReportAgentUsage;
|
||
generate(
|
||
bundle: ReportEvidenceBundleV2,
|
||
plan: PersonalReportSectionPlan,
|
||
options?: ReportAgentGenerateOptions,
|
||
): Promise<PersonalReportAgentOutput>;
|
||
generateSection?: (
|
||
bundle: ReportEvidenceBundleV2,
|
||
section: ReportSectionPlanEntry,
|
||
completedTitles: readonly string[],
|
||
options?: ReportAgentSectionOptions,
|
||
) => Promise<z.output<typeof personalReportThematicNarrativeSchema>>;
|
||
generateSummary?: (
|
||
sections: readonly Readonly<{ title: string; claimStatus: string }>[],
|
||
options?: ReportAgentSummaryOptions,
|
||
) => Promise<z.output<typeof personalReportExecutiveSummarySchema>>;
|
||
}>;
|
||
|
||
const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
|
||
|
||
export type WriterRepairCategory =
|
||
| "identity mismatch"
|
||
| "refs mismatch"
|
||
| "schema invalid"
|
||
| "output truncated";
|
||
|
||
export function classifyWriterRepairCategory(input: Readonly<{
|
||
finishReason?: string | null;
|
||
error?: unknown;
|
||
}>): WriterRepairCategory {
|
||
if (input.finishReason === "length") return "output truncated";
|
||
const message = input.error instanceof Error ? input.error.message : "";
|
||
if (
|
||
message.includes("report_writer_section_identity_mismatch")
|
||
|| message.includes("report_writer_unplanned_theme")
|
||
|| message.includes("report_writer_section_id_mismatch")
|
||
) {
|
||
return "identity mismatch";
|
||
}
|
||
if (message.includes("report_writer_evidence_refs_mismatch")) return "refs mismatch";
|
||
return "schema invalid";
|
||
}
|
||
|
||
export function writerRepairPromptSuffix(input: Readonly<{
|
||
finishReason?: string | null;
|
||
error?: unknown;
|
||
targetCharactersMin?: number;
|
||
}>): string {
|
||
const category = classifyWriterRepairCategory(input);
|
||
const extras: string[] = [`失败类别:${category}。`];
|
||
if (category === "output truncated") {
|
||
const min = input.targetCharactersMin;
|
||
extras.push(
|
||
typeof min === "number" && Number.isFinite(min)
|
||
? `请把 narrative 压缩到约 ${min} 字,并保证 JSON 完整闭合。`
|
||
: "请压缩篇幅并保证 JSON 完整闭合。",
|
||
);
|
||
}
|
||
if (category === "refs mismatch") {
|
||
extras.push("evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。");
|
||
}
|
||
return `${REPAIR_PROMPT_SUFFIX}${extras.join("")}`;
|
||
}
|
||
|
||
type GenerationResult = { object?: unknown; usage?: unknown; finishReason?: unknown };
|
||
|
||
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
|
||
if (signal?.aborted) return true;
|
||
return error instanceof Error && error.name === "AbortError";
|
||
}
|
||
|
||
function sectionPrompt(
|
||
bundle: ReportEvidenceBundleV2,
|
||
section: ReportSectionPlanEntry,
|
||
completedTitles: readonly string[],
|
||
): string {
|
||
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
|
||
}
|
||
|
||
/**
|
||
* System content sent ahead of the report input. Index 0 is the prompt-cache
|
||
* prefix and stays byte-identical for every call so the cache is actually
|
||
* reused; the per-theme pack follows it and is intentionally not cached.
|
||
* Summary calls get the general pack only.
|
||
*/
|
||
export function buildWriterSystemContents(theme: string | null | undefined): readonly string[] {
|
||
const cached = `${cachedInterpretationPreamble()}\n\n【上下文缓存边界】后续内容为本次报告输入。`;
|
||
const themePack = theme ? interpretationPackForTheme(theme) : null;
|
||
return themePack ? [cached, themePack] : [cached];
|
||
}
|
||
|
||
function summaryPrompt(
|
||
sections: readonly Readonly<{ title: string; claimStatus: string }>[],
|
||
): string {
|
||
return `请根据全部已完成主题的标题和 claimStatus 生成个人报告 executiveSummary。严格输出 JSON 对象,不要 Markdown 或额外文字。不得编造未列出的主题或精确时间。\n${JSON.stringify({ sections })}`;
|
||
}
|
||
|
||
export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportAgentPort {
|
||
const agent = new Agent({
|
||
id: `personal-report-${model.id}`,
|
||
name: "Personal Report Writer",
|
||
model: model.model,
|
||
instructions: personalReportInstructions,
|
||
});
|
||
|
||
let usageTotals: ReportAgentUsage = { inputTokens: 0, outputTokens: 0 };
|
||
const recordUsage = async (usage: unknown) => {
|
||
const tokens = await readUsage(usage);
|
||
const cache = mergePromptCacheUsage([usageTotals.cache, tokens.cache]);
|
||
usageTotals = {
|
||
inputTokens: usageTotals.inputTokens + Math.max(0, tokens.inputTokens ?? 0),
|
||
outputTokens: usageTotals.outputTokens + Math.max(0, tokens.outputTokens ?? 0),
|
||
...(cache ? { cache } : {}),
|
||
};
|
||
};
|
||
|
||
const runStructured = async <T>(input: Readonly<{
|
||
prompt: string;
|
||
schema: z.ZodType<T>;
|
||
signal?: AbortSignal;
|
||
maxOutputTokens?: number;
|
||
/** Theme whose interpretation pack rides after the cache boundary. */
|
||
themePack?: string | null;
|
||
/** Metric only: interpretive rows in the bundle this call received. */
|
||
interpretiveFactCount?: number;
|
||
targetCharactersMin?: number;
|
||
accept: (value: T) => void;
|
||
}>): Promise<T> => {
|
||
const startedAt = Date.now();
|
||
const signal = input.signal;
|
||
const prompt = input.prompt;
|
||
let repairAttempted = false;
|
||
let attemptReturned = false;
|
||
// The general interpretation pack sits INSIDE the cached system message so
|
||
// the cached prefix stays byte-identical across every section call. The
|
||
// per-theme pack changes each section, so it goes after the boundary.
|
||
const [cachedContent, ...uncachedContents] = buildWriterSystemContents(input.themePack);
|
||
const cacheBoundary = cachedSystemMessage(cachedContent, model.model)
|
||
?? { role: "system" as const, content: cachedContent };
|
||
const metrics = {
|
||
interpretiveFactCount: input.interpretiveFactCount ?? 0,
|
||
knowledgePackCharacters: cachedContent.length
|
||
+ uncachedContents.reduce((total, item) => total + item.length, 0),
|
||
};
|
||
const runOnce = (content: string) => agent.generate(
|
||
[
|
||
cacheBoundary,
|
||
...uncachedContents.map((item) => ({ role: "system" as const, content: item })),
|
||
{ role: "user", content },
|
||
],
|
||
{
|
||
abortSignal: signal,
|
||
structuredOutput: { schema: input.schema, jsonPromptInjection: "inline" as const },
|
||
...(input.maxOutputTokens === undefined ? {} : agentGenerationSettings(model.model, {
|
||
thinking: "disabled",
|
||
answerTokens: input.maxOutputTokens,
|
||
})),
|
||
},
|
||
);
|
||
const accept = (result: GenerationResult): { ok: true; data: T } | { ok: false; error?: unknown } => {
|
||
const parsed = input.schema.safeParse(result.object);
|
||
if (!parsed.success) return { ok: false };
|
||
try {
|
||
input.accept(parsed.data);
|
||
return { ok: true, data: parsed.data };
|
||
} catch (error) {
|
||
if (isAbortError(error, input.signal)) throw error;
|
||
return { ok: false, error };
|
||
}
|
||
};
|
||
try {
|
||
attemptReturned = false;
|
||
const first = await runOnce(prompt);
|
||
attemptReturned = true;
|
||
const accepted = accept(first);
|
||
if (accepted.ok) {
|
||
await recordUsage(first.usage);
|
||
await logTelemetry(model.id, startedAt, false, "resolved", first.usage, first.finishReason, metrics);
|
||
return accepted.data;
|
||
}
|
||
await recordUsage(first.usage);
|
||
await logTelemetry(model.id, startedAt, false, "failed", first.usage, first.finishReason, metrics);
|
||
const firstFinishReason = await readFinishReason(first.finishReason);
|
||
repairAttempted = true;
|
||
attemptReturned = false;
|
||
const repairSuffix = writerRepairPromptSuffix({
|
||
finishReason: firstFinishReason,
|
||
error: accepted.error,
|
||
targetCharactersMin: input.targetCharactersMin,
|
||
});
|
||
const repaired = await runOnce(`${prompt}${repairSuffix}`);
|
||
attemptReturned = true;
|
||
const repairedAccepted = accept(repaired);
|
||
if (repairedAccepted.ok) {
|
||
await recordUsage(repaired.usage);
|
||
await logTelemetry(model.id, startedAt, true, "resolved", repaired.usage, repaired.finishReason, metrics);
|
||
return repairedAccepted.data;
|
||
}
|
||
await recordUsage(repaired.usage);
|
||
await logTelemetry(model.id, startedAt, true, "failed", repaired.usage, repaired.finishReason, metrics);
|
||
if (repairedAccepted.error) throw repairedAccepted.error;
|
||
throw new PersonalReportAgentOutputError(await readFinishReason(repaired.finishReason) ?? firstFinishReason);
|
||
} catch (error) {
|
||
if (error instanceof PersonalReportAgentOutputError) throw error;
|
||
if (isAbortError(error, input.signal)) throw error;
|
||
if (error instanceof Error && error.message.startsWith("report_writer_")) throw error;
|
||
if (!attemptReturned) await logTelemetry(model.id, startedAt, repairAttempted, "failed", null, null, metrics);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
return {
|
||
modelId: model.id,
|
||
getUsage: () => usageTotals,
|
||
generate: (bundle, plan, options) => {
|
||
const signal = options?.signal;
|
||
return runStructured({
|
||
prompt: buildReportPrompt(bundle, plan),
|
||
schema: personalReportAgentOutputSchema,
|
||
signal,
|
||
interpretiveFactCount: countInterpretiveFacts(bundle),
|
||
accept: (output) => options?.assertWriterOutput?.(output),
|
||
});
|
||
},
|
||
generateSection: (bundle, section, completedTitles, options) => runStructured({
|
||
prompt: sectionPrompt(bundle, section, completedTitles),
|
||
schema: personalReportThematicNarrativeSchema,
|
||
signal: options?.signal,
|
||
maxOutputTokens: options?.maxOutputTokens,
|
||
targetCharactersMin: options?.targetCharactersMin ?? section.targetCharacters?.min,
|
||
themePack: section.theme,
|
||
interpretiveFactCount: countInterpretiveFacts(bundle),
|
||
accept: (output) => options?.assertWriterOutput?.(output),
|
||
}),
|
||
generateSummary: (sections, options) => runStructured({
|
||
prompt: summaryPrompt(sections),
|
||
schema: personalReportExecutiveSummarySchema,
|
||
signal: options?.signal,
|
||
maxOutputTokens: options?.maxOutputTokens,
|
||
accept: () => undefined,
|
||
}),
|
||
};
|
||
}
|
||
|
||
async function logTelemetry(
|
||
modelId: string,
|
||
startedAt: number,
|
||
repairAttempted: boolean,
|
||
outcome: "resolved" | "failed",
|
||
usage: unknown,
|
||
finishReason: unknown,
|
||
metrics: Readonly<{ interpretiveFactCount: number; knowledgePackCharacters: number }>,
|
||
) {
|
||
const [tokens, finishReasonValue] = await Promise.all([
|
||
readUsage(usage),
|
||
readFinishReason(finishReason),
|
||
]);
|
||
const telemetry: PersonalReportAgentTelemetry = {
|
||
modelId,
|
||
outcome,
|
||
elapsedMs: Math.max(0, Date.now() - startedAt),
|
||
inputTokens: tokens.inputTokens,
|
||
outputTokens: tokens.outputTokens,
|
||
totalTokens: tokens.totalTokens,
|
||
finishReason: finishReasonValue,
|
||
repairAttempted,
|
||
interpretiveFactCount: metrics.interpretiveFactCount,
|
||
knowledgePackCharacters: metrics.knowledgePackCharacters,
|
||
};
|
||
// Telemetry must never include the prompt, the packet, birth data or the
|
||
// report body. interpretiveFactCount and knowledgePackCharacters are counts
|
||
// and lengths only — never the facts, the seeds or the pack text.
|
||
console.info("[personal-report-agent]", JSON.stringify(telemetry));
|
||
}
|