feat(report): birth-sky report cover bound to the report's birth fingerprint (F1–F3)
Product decision A (TASK-birth-sky-followup-20260928): - every generation path writes subject.birthFingerprint (stored-row fingerprint, same function as the chart page's profileFingerprint); never into the evidence bundle the model reads - GET /api/reports/:id compares on the server and returns only report.coverSubject (subject id or null); legacy reports never get one - report page shows the night cover above the body, paper cover alone on the first printed A4 page; silent on any failure, no waiting state - TS / JSON schema / Python contract accept the optional field - BLOCKED T5 struck through; DESIGN §17, CHANGELOG, checklist, PROGRESS Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
14ef18ac24
commit
a9a917d50c
@@ -0,0 +1,34 @@
|
||||
import type { BirthSkyOk } from "./contract.ts";
|
||||
import { fetchBirthSky } from "./cover.ts";
|
||||
import type { BirthSkyPaletteName } from "./draw.ts";
|
||||
import { birthSkyFactLine, birthSkyHeadline } from "./sentence.ts";
|
||||
|
||||
/** Screen (`night`) and print (`paper`) pictures of one report cover, painted by the same `drawBirthSky`. */
|
||||
export type ReportSkyCoverPngs = Readonly<{ screen: Blob; print: Blob; description: string }>;
|
||||
|
||||
export type ReportSkyCoverDeps = Readonly<{
|
||||
fetchSky: (subjectId: string, signal?: AbortSignal) => Promise<BirthSkyOk | null>;
|
||||
render: (sky: BirthSkyOk, palette: BirthSkyPaletteName) => Promise<Blob>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Report cover loader (TASK-birth-sky-followup-20260928 F3). Resolves null on
|
||||
* any failure or abort; the report shows no cover and nothing else changes.
|
||||
*/
|
||||
export async function loadReportSkyCover(
|
||||
subjectId: string,
|
||||
signal: AbortSignal,
|
||||
deps?: ReportSkyCoverDeps,
|
||||
): Promise<ReportSkyCoverPngs | null> {
|
||||
try {
|
||||
const fetchSky = deps?.fetchSky ?? fetchBirthSky;
|
||||
const render = deps?.render ?? (await import("./render.ts")).renderBirthSkyPng;
|
||||
const sky = await fetchSky(subjectId, signal);
|
||||
if (!sky || signal.aborted) return null;
|
||||
const [screen, print] = await Promise.all([render(sky, "night"), render(sky, "paper")]);
|
||||
if (signal.aborted) return null;
|
||||
return { screen, print, description: `${birthSkyHeadline(sky)}${birthSkyFactLine(sky)}。` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,10 @@ const subjectSchema = z.strictObject({
|
||||
displayName: text(120),
|
||||
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
|
||||
birthPlaceLabel: text(200),
|
||||
// Fingerprint of the birth data the report was generated from (report cover
|
||||
// binding). Optional: reports generated before it existed stay readable and
|
||||
// never show a cover. Server-only; the reader never receives the subject.
|
||||
birthFingerprint: z.string().regex(/^[0-9a-f]{32}$/, "invalid birth fingerprint").optional(),
|
||||
});
|
||||
|
||||
const provenanceV1Schema = z.strictObject({
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
type ReportSectionPlanEntry,
|
||||
} from "./personal-report-plan.ts";
|
||||
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.ts";
|
||||
import { withReportBirthFingerprint } from "./report-birth-binding.ts";
|
||||
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
|
||||
// directly (the pure, dependency-free codes module).
|
||||
export { REPORT_STABLE_CODES } from "./personal-report-codes.ts";
|
||||
@@ -3275,6 +3276,8 @@ export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readon
|
||||
requestId?: string;
|
||||
sectionService?: PersonalReportSectionService;
|
||||
onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise<void> | void;
|
||||
/** Stored-row birth fingerprint; written to `subject.birthFingerprint`, never into the bundle the model reads. */
|
||||
birthFingerprint?: string;
|
||||
}>;
|
||||
|
||||
export type ReportSchemaInnerReason = string;
|
||||
@@ -3588,7 +3591,7 @@ async function generateSectionedPersonalReport(
|
||||
depth: deps.depth, bundle, plan, agentOutput, allowIncompleteThematic: true,
|
||||
additionalBlockedSections: blockedDisclosures,
|
||||
});
|
||||
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
|
||||
const guarded = applyReportGuard(withReportBirthFingerprint(candidate, deps.birthFingerprint), buildLegacyPacketFromBundle(bundle));
|
||||
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
|
||||
const parsed = safeParseServerReportDocument(guarded.document);
|
||||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failFinalParse(parsed);
|
||||
@@ -3687,7 +3690,7 @@ export async function generatePersonalReport(
|
||||
: classifyReportSchemaInnerReason(error),
|
||||
);
|
||||
}
|
||||
const guarded = applyReportGuard(candidate, packet);
|
||||
const guarded = applyReportGuard(withReportBirthFingerprint(candidate, deps.birthFingerprint), packet);
|
||||
if (!guarded.ok) {
|
||||
return { status: "failed", failureCode: "report_guard_rejected" };
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ export type LongformCoverInput = Readonly<{
|
||||
birthTimeStatus: ReportDocumentV2["subject"]["birthTimeStatus"];
|
||||
birthPlaceLabel: string;
|
||||
}>;
|
||||
/** Stored-row birth fingerprint (report cover binding); absent → no `subject.birthFingerprint`. */
|
||||
birthFingerprint?: string;
|
||||
markdown: string;
|
||||
factTables?: ReportDocumentV2["factTables"];
|
||||
charts?: ReportDocumentV2["charts"];
|
||||
@@ -88,7 +90,7 @@ export function buildLongformCoverDocument(input: LongformCoverInput): ReportDoc
|
||||
depth: input.report.depth,
|
||||
requestedThemes: [...input.report.requestedThemes],
|
||||
generatedAt: input.generatedAt ?? new Date().toISOString(),
|
||||
subject: input.subject,
|
||||
subject: input.birthFingerprint ? { ...input.subject, birthFingerprint: input.birthFingerprint } : input.subject,
|
||||
provenance: {
|
||||
skillName,
|
||||
skillVersion,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "./personal-report-longform-appendix";
|
||||
import { buildLongformBirthPayload, utcToday } from "./personal-report-longform-birth";
|
||||
import { buildLongformCoverDocument } from "./personal-report-longform-cover";
|
||||
import { readReportBirthFingerprint } from "./report-birth-binding";
|
||||
import { assembleLongformCharts, type GeneratePersonalReportResult } from "./personal-report-generation";
|
||||
import type { PersonalReportRecord } from "./personal-report-service-core";
|
||||
import type { ReportDocumentV2 } from "./personal-report-contract";
|
||||
@@ -261,6 +262,7 @@ export async function generatePersonalReportLongform(
|
||||
birthTimeStatus: deps.birthTimeStatus,
|
||||
birthPlaceLabel: text(profile.birth_place_label) ?? "未知出生地",
|
||||
},
|
||||
birthFingerprint: readReportBirthFingerprint(profile),
|
||||
markdown,
|
||||
factTables,
|
||||
charts,
|
||||
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
type SkillSnapshot,
|
||||
} from "./personal-report-generation";
|
||||
import { REPORT_STABLE_CODES } from "./personal-report-codes";
|
||||
import {
|
||||
readReportBirthFingerprint,
|
||||
reportCoverSubject,
|
||||
reportDocumentBirthFingerprint,
|
||||
reportSubjectId,
|
||||
} from "./report-birth-binding";
|
||||
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
|
||||
import {
|
||||
deriveSectionProgressState,
|
||||
@@ -149,6 +155,7 @@ export function reportView(
|
||||
job?: PersonalReportJobRecord | null,
|
||||
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
|
||||
sections?: readonly ReportSectionProgress[] | null,
|
||||
coverSubject?: string | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -166,6 +173,8 @@ export function reportView(
|
||||
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
|
||||
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
|
||||
...(failure?.appendixLastErrorCode ? { appendixLastErrorCode: failure.appendixLastErrorCode } : {}),
|
||||
// Report cover binding: a subject id or null, never the fingerprint itself.
|
||||
...(coverSubject !== undefined ? { coverSubject } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,6 +560,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
|
||||
depth: payload.depth,
|
||||
agent: deps.createAgent(selectedModel),
|
||||
now: deps.now,
|
||||
birthFingerprint: readReportBirthFingerprint(profile),
|
||||
});
|
||||
|
||||
if (result.status === "failed") {
|
||||
@@ -678,8 +688,30 @@ export type ReportReadCoreDeps = Readonly<{
|
||||
lastErrorCode: string | null;
|
||||
markdown: string | null;
|
||||
} | null>;
|
||||
/**
|
||||
* Current birth fingerprint of one of the reader's own subjects (`"self"` or
|
||||
* a chart profile id), read on the server. Null or a throw means no cover.
|
||||
*/
|
||||
loadCurrentBirthFingerprint?: (userId: string, subjectId: string) => Promise<string | null>;
|
||||
}>;
|
||||
|
||||
/** Server-side comparison for the report cover; only its verdict leaves the server. */
|
||||
async function readyReportCoverSubject(
|
||||
deps: ReportReadCoreDeps,
|
||||
userId: string,
|
||||
row: PersonalReportRecord,
|
||||
): Promise<string | null> {
|
||||
const storedFingerprint = reportDocumentBirthFingerprint(row.reportDocument);
|
||||
if (!storedFingerprint || !deps.loadCurrentBirthFingerprint) return null;
|
||||
let currentFingerprint: string | null = null;
|
||||
try {
|
||||
currentFingerprint = await deps.loadCurrentBirthFingerprint(userId, reportSubjectId(row.chartProfileId));
|
||||
} catch {
|
||||
currentFingerprint = null;
|
||||
}
|
||||
return reportCoverSubject({ chartProfileId: row.chartProfileId, storedFingerprint, currentFingerprint });
|
||||
}
|
||||
|
||||
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
|
||||
const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins, deps.requestHeaders);
|
||||
if (!originDecision.ok) {
|
||||
@@ -727,6 +759,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
))
|
||||
: null;
|
||||
if (row.status === "ready") {
|
||||
const coverSubject = await readyReportCoverSubject(deps, deps.userId, row);
|
||||
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
|
||||
? appendix.markdown
|
||||
: null;
|
||||
@@ -739,7 +772,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
report: reportView(row, job),
|
||||
report: reportView(row, job, null, null, coverSubject),
|
||||
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
|
||||
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
|
||||
? { calculationCharts: validated.document.charts } : {}),
|
||||
@@ -759,7 +792,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
if (deps.loadLongformMarkdown || deps.loadLongformAppendix) {
|
||||
return {
|
||||
status: 200,
|
||||
body: { report: reportView(row, job), longformMarkdown: null },
|
||||
body: { report: reportView(row, job, null, null, coverSubject), longformMarkdown: null },
|
||||
};
|
||||
}
|
||||
if (!validated.ok) {
|
||||
@@ -768,13 +801,13 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
|
||||
body: {
|
||||
error: "报告内容未通过合同校验",
|
||||
code: REPORT_STABLE_CODES.schemaInvalid,
|
||||
report: reportView(row, job),
|
||||
report: reportView(row, job, null, null, coverSubject),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: { report: reportView(row, job), reportDocument: projectOrdinaryReportDocument(validated.document) },
|
||||
body: { report: reportView(row, job, null, null, coverSubject), reportDocument: projectOrdinaryReportDocument(validated.document) },
|
||||
};
|
||||
}
|
||||
return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts";
|
||||
import { resolveReportBirthClock } from "./personal-report-route-core.ts";
|
||||
import { PersonalReportWorkerError } from "./personal-report-worker-core.ts";
|
||||
import { stampReportBirthFingerprint } from "./report-birth-binding.ts";
|
||||
import { ConsultationSubjectError, loadSubjectBirth } from "./subject-birth.ts";
|
||||
|
||||
export async function loadReportWorkerProfile(client: unknown, userId: string, subjectId: string): Promise<unknown> {
|
||||
@@ -20,7 +21,8 @@ export async function loadReportWorkerProfile(client: unknown, userId: string, s
|
||||
if (error) throw new PersonalReportWorkerError("profile_incomplete", true);
|
||||
row = { ...row, rectification_case_id: data?.rectification_case_id ?? null };
|
||||
}
|
||||
return await resolveMissingBirthTimezoneOffset(row, { useActiveDate: true });
|
||||
// The cover binding fingerprint is taken from the stored row, before the fill.
|
||||
return stampReportBirthFingerprint(resolved.row, await resolveMissingBirthTimezoneOffset(row, { useActiveDate: true }));
|
||||
} catch (error) {
|
||||
if (error instanceof ConsultationSubjectError) {
|
||||
// Missing/deleted/foreign subjects are terminal and release the existing
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { ConsultationInput } from "@/mastra/consultation-workflow";
|
||||
import { resolveAyanamsa } from "@/lib/ayanamsa";
|
||||
|
||||
import { loadReportWorkerProfile, reportWorkerUsesCandidateRange } from "@/lib/personal-report-worker-subject";
|
||||
import { readReportBirthFingerprint } from "@/lib/report-birth-binding";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type WorkerGlobal = typeof globalThis & {
|
||||
@@ -183,6 +184,7 @@ async function generateWriterReport(
|
||||
requestId: context.report.requestId,
|
||||
sectionService: context.sectionService,
|
||||
onProgress: context.onProgress,
|
||||
birthFingerprint: readReportBirthFingerprint(profile),
|
||||
});
|
||||
if (result.status !== "ready") return result;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { resolveServerOwnedChartBirth } from "./chart-birth-truth.ts";
|
||||
|
||||
/**
|
||||
* Report ↔ birth-data binding for the report cover (TASK-birth-sky-followup-20260928,
|
||||
* product decision A).
|
||||
*
|
||||
* A report records the fingerprint of the birth data it was generated from, in
|
||||
* `subject.birthFingerprint`. The fingerprint is the chart page's
|
||||
* `profileFingerprint`: `resolveServerOwnedChartBirth` over the **stored** row,
|
||||
* before any in-memory timezone fill. Reports generated before this field
|
||||
* existed never get one and never show a cover. Neither the fingerprint nor
|
||||
* any birth field leaves the server; the reader only receives `coverSubject`.
|
||||
*/
|
||||
|
||||
/** Server-only key stamped on the loaded profile row; never persisted, never serialized to a client. */
|
||||
export const REPORT_BIRTH_FINGERPRINT_KEY = "report_birth_fingerprint";
|
||||
|
||||
const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Same algorithm and same input (the stored row) as the chart page's `profileFingerprint`. */
|
||||
export function subjectBirthFingerprint(storedRow: unknown): string {
|
||||
return resolveServerOwnedChartBirth(storedRow).fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the stored row's fingerprint on the timezone-filled row the
|
||||
* generators read. Filling a missing offset changes the fingerprint, so it
|
||||
* must be taken before the fill.
|
||||
*/
|
||||
export function stampReportBirthFingerprint(storedRow: unknown, filledRow: unknown): unknown {
|
||||
const filled = record(filledRow);
|
||||
if (!filled) return filledRow;
|
||||
return { ...filled, [REPORT_BIRTH_FINGERPRINT_KEY]: subjectBirthFingerprint(storedRow) };
|
||||
}
|
||||
|
||||
export function readReportBirthFingerprint(profile: unknown): string | undefined {
|
||||
const value = record(profile)?.[REPORT_BIRTH_FINGERPRINT_KEY];
|
||||
return typeof value === "string" && FINGERPRINT_PATTERN.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/** Writes `subject.birthFingerprint` on a report document; a missing fingerprint leaves it untouched. */
|
||||
export function withReportBirthFingerprint<T>(document: T, fingerprint: string | undefined): T {
|
||||
const source = record(document);
|
||||
const subject = record(source?.subject);
|
||||
if (!source || !subject || !fingerprint) return document;
|
||||
return { ...source, subject: { ...subject, birthFingerprint: fingerprint } } as T;
|
||||
}
|
||||
|
||||
export function reportDocumentBirthFingerprint(document: unknown): string | null {
|
||||
const value = record(record(document)?.subject)?.birthFingerprint;
|
||||
return typeof value === "string" && FINGERPRINT_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/** The subject id the reader may ask `/api/birth-sky` for; `"self"` when the report has no chart profile. */
|
||||
export function reportSubjectId(chartProfileId: string | null): string {
|
||||
return chartProfileId && chartProfileId.trim() ? chartProfileId.trim() : "self";
|
||||
}
|
||||
|
||||
/**
|
||||
* `coverSubject` for `reportView`: the subject id only when the stored
|
||||
* fingerprint equals the subject's current one; null otherwise (mismatch,
|
||||
* legacy report without the field, foreign or deleted person, lookup failure).
|
||||
*/
|
||||
export function reportCoverSubject(input: Readonly<{
|
||||
chartProfileId: string | null;
|
||||
storedFingerprint: string | null;
|
||||
currentFingerprint: string | null;
|
||||
}>): string | null {
|
||||
if (!input.storedFingerprint || !input.currentFingerprint) return null;
|
||||
return input.storedFingerprint === input.currentFingerprint ? reportSubjectId(input.chartProfileId) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user