diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts new file mode 100644 index 00000000..f1f31de7 --- /dev/null +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from "next/server"; +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement"; +import { + resolveReportDelete, + resolveReportRead, +} from "@/lib/personal-report-route-core"; +import { safeParseServerReportDocument } from "@/lib/personal-report-contract.server"; +import { + createSupabasePersonalReportService, + type PersonalReportService, +} from "@/lib/personal-report-service"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ reportId: string }> }; + +function sanitizedErrorCode(error: unknown): string { + if (error instanceof Error) return error.name; + return "UnknownError"; +} + +function toNextResponse(response: { status: number; body: Record }) { + return NextResponse.json(response.body, { status: response.status }); +} + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * GET/DELETE use the AUTHENTICATED client: RLS permits owners to select and + * delete their own rows only, and the persistence service additionally scopes + * every query by userId (least privilege — no service role here). + */ +async function resolvePersistenceForUser() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return { userId: null as string | null, persistence: null as PersonalReportService | null }; + } + const persistence = createSupabasePersonalReportService(supabase); + return { userId: user.id, persistence }; +} + +export async function GET(request: Request, context: RouteContext) { + try { + const { userId, persistence } = await resolvePersistenceForUser(); + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json( + { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + { status: 404 }, + ); + } + const response = await resolveReportRead({ + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + reportId, + persistence: persistence ?? { + async getOwnedById() { + return null; + }, + }, + // Defense in depth: a stored ready document is re-validated through the + // canonical server parse (schema + guards + evidence hash recompute) + // before it is returned to the browser. Client-side validation is never + // a substitute. + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok + ? { ok: true, document: parsed.document } + : { ok: false }; + }, + }); + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] read failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} + +export async function DELETE(request: Request, context: RouteContext) { + try { + const { userId, persistence } = await resolvePersistenceForUser(); + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json( + { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + { status: 404 }, + ); + } + const response = await resolveReportDelete({ + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + reportId, + persistence: persistence ?? { + async getOwnedById() { + return null; + }, + async deleteOwned() { + return false; + }, + }, + }); + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] delete failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告暂时无法删除", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts new file mode 100644 index 00000000..588e7470 --- /dev/null +++ b/frontend/src/app/api/reports/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { runConsultationWorkflow } from "@/mastra"; +import { createPersonalReportAgent } from "@/mastra/personal-report"; +import { defaultLanguageModel } from "@/mastra/model"; +import { + resolveSkillSnapshot, +} from "@/lib/personal-report-generation"; +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { + isPersonalReportFeatureEnabled, + readPersonalReportDailyLimit, + resolveAllowedReportOrigins, +} from "@/lib/personal-report-entitlement"; +import { + resolveReportCreate, + type ReportCreateCoreDeps, +} from "@/lib/personal-report-route-core"; +import { + createPersonalReportDataClient, + createSupabasePersonalReportService, + type PersonalReportService, +} from "@/lib/personal-report-service"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +function sanitizedErrorCode(error: unknown): string { + if (error instanceof Error) return error.name; + return "UnknownError"; +} + +function toNextResponse(response: { status: number; body: Record }) { + return NextResponse.json(response.body, { status: response.status }); +} + +export async function POST(request: Request) { + try { + // Authenticated client: auth, profile, session/chart-profile owner reads. + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + const userId = authError || !user ? null : user.id; + + // Admin client (service_role / self-hosted admin DB): generation writes + // and counting. The authenticated client is forbidden by migration grants + // from inserting/updating personal_reports. + const admin = createAdminSupabaseClient(); + const persistence: PersonalReportService = createSupabasePersonalReportService(admin); + const adminDataClient = createPersonalReportDataClient(admin); + + let profile: unknown = null; + let profileError: unknown = null; + if (userId) { + const result = await supabase + .from("profiles") + .select("name,birth_date,active_birth_time,birth_time_status,latitude,longitude,timezone_offset,birth_place_label") + .eq("id", userId) + .maybeSingle(); + profile = result.data ?? null; + profileError = result.error; + } + + const deps: ReportCreateCoreDeps = { + requestUrl: request.url, + origin: request.headers.get("origin"), + allowedOrigins: resolveAllowedReportOrigins(process.env), + userId, + rawBody: await request.json().catch(() => null), + profile, + checkSessionOwned: async (sessionId) => { + const { data, error } = await supabase + .from("chat_sessions") + .select("id") + .eq("id", sessionId) + .eq("user_id", userId as string) + .maybeSingle(); + if (error) throw error; + return Boolean(data); + }, + checkChartProfileOwned: async (chartProfileId) => { + const { data, error } = await supabase + .from("chart_profiles") + .select("id") + .eq("id", chartProfileId) + .eq("user_id", userId as string) + .maybeSingle(); + if (error) throw error; + return Boolean(data); + }, + featureEnabled: isPersonalReportFeatureEnabled(process.env), + dailyLimit: readPersonalReportDailyLimit(process.env), + counts: { + countGenerating: async () => { + const { data, error } = await adminDataClient.from("personal_reports") + .select("id") + .eq("user_id", userId as string) + .eq("status", "generating") + .limit(2); + if (error) throw error; + return Array.isArray(data) ? data.length : 0; + }, + countCreatedToday: async () => { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const { data, error } = await adminDataClient.from("personal_reports") + .select("id,created_at") + .eq("user_id", userId as string); + if (error) throw error; + if (!Array.isArray(data)) return 0; + const startIso = todayStart.toISOString(); + return data.filter((row) => { + const createdAt = row && typeof row === "object" + ? (row as Record).created_at + : null; + return typeof createdAt === "string" && createdAt >= startIso; + }).length; + }, + }, + persistence, + model: defaultLanguageModel(), + runWorkflow: (input) => runConsultationWorkflow(input), + createAgent: (model) => createPersonalReportAgent(model as Parameters[0]), + skillSnapshot: resolveSkillSnapshot(), + }; + + const response = await resolveReportCreate(deps); + if (response.status >= 500 && profileError) { + console.error(`[reports] create failed request=${String(deps.rawBody && typeof deps.rawBody === "object" + ? (deps.rawBody as Record).requestId ?? "unknown" + : "unknown")} reason=${sanitizedErrorCode(profileError)}`); + } + return toNextResponse(response); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json( + { error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + console.error(`[reports] create failed reason=${sanitizedErrorCode(error)}`); + return NextResponse.json( + { error: "报告生成暂时不可用", code: REPORT_STABLE_CODES.generationFailed }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/lib/personal-report-codes.ts b/frontend/src/lib/personal-report-codes.ts new file mode 100644 index 00000000..790d4712 --- /dev/null +++ b/frontend/src/lib/personal-report-codes.ts @@ -0,0 +1,23 @@ +/** + * Stable error/failure codes for the personal report API. Kept in a pure + * dependency-free module (no filesystem, no path, no crypto imports) so route + * handlers that only need codes never pull the skill-snapshot scanner or any + * other generation logic into their bundle/trace. + */ + +export const REPORT_STABLE_CODES = { + profileIncomplete: "profile_incomplete", + birthTimeNotUsable: "birth_time_not_usable", + generationInProgress: "report_generation_in_progress", + rateLimited: "report_rate_limited", + calculationUnavailable: "calculation_unavailable", + modelUnavailable: "model_unavailable", + schemaInvalid: "report_schema_invalid", + guardRejected: "report_guard_rejected", + notFound: "report_not_found", + requestConflict: "report_request_conflict", + exportDisabled: "report_export_disabled", + invalidRequest: "invalid_request", + resourceForbidden: "report_resource_forbidden", + generationFailed: "report_generation_failed", +} as const; diff --git a/frontend/src/lib/personal-report-entitlement.ts b/frontend/src/lib/personal-report-entitlement.ts new file mode 100644 index 00000000..99c304b0 --- /dev/null +++ b/frontend/src/lib/personal-report-entitlement.ts @@ -0,0 +1,103 @@ +/** + * Personal report export entitlement — independent from the "spend 1 credit" + * consultation RPC. Capability key: report.export.personal. + * + * Staging free policy: login-only, single concurrent generation per user, + * daily limit read from environment configuration (never hardcoded in UI). + * If the feature is later priced, a reserve/refund flow plugs in behind the + * same interface; this module stays billing-agnostic. + */ + +export const REPORT_EXPORT_PERSONAL_CAPABILITY_KEY = "report.export.personal"; + +export const REPORT_FEATURE_ENV = "PERSONAL_REPORT_ENABLED"; +export const REPORT_DAILY_LIMIT_ENV = "PERSONAL_REPORT_DAILY_LIMIT"; +export const REPORT_ALLOWED_ORIGINS_ENV = "PERSONAL_REPORT_ALLOWED_ORIGINS"; + +/** + * Server-side default when the env variable is absent. The UI must never + * hardcode this number; it is configurable per deployment. + */ +export const DEFAULT_PERSONAL_REPORT_DAILY_LIMIT = 5; + +export type Environment = Readonly>; + +export function readPersonalReportDailyLimit(environment: Environment): number { + const raw = environment[REPORT_DAILY_LIMIT_ENV]?.trim(); + if (!raw) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PERSONAL_REPORT_DAILY_LIMIT; + return parsed; +} + +export function isPersonalReportFeatureEnabled(environment: Environment): boolean { + return environment[REPORT_FEATURE_ENV]?.trim() === "true"; +} + +export function resolveAllowedReportOrigins(environment: Environment): readonly string[] { + return (environment[REPORT_ALLOWED_ORIGINS_ENV] ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +export type SameOriginDecision = Readonly< + { ok: true } | { ok: false; code: "cross_origin_forbidden" } +>; + +/** + * Same-origin check for report APIs. An absent Origin header (curl, server + * tests, same-origin fetch from the browser never sends Origin for GET but + * does for POST) is accepted; a matching request origin is accepted; a + * configured trusted-proxy/test allowlist is accepted; anything else is + * rejected. + */ +export function checkSameOrigin( + requestUrl: string | URL, + originHeader: string | null, + allowedOrigins: readonly string[], +): SameOriginDecision { + const origin = originHeader?.trim(); + if (!origin) return { ok: true }; + let requestOrigin: string; + try { + requestOrigin = new URL(requestUrl).origin; + } catch { + return { ok: false, code: "cross_origin_forbidden" }; + } + if (origin === requestOrigin) return { ok: true }; + if (allowedOrigins.includes(origin)) return { ok: true }; + return { ok: false, code: "cross_origin_forbidden" }; +} + +export type PersonalReportEntitlementResult = Readonly< + | { allowed: true } + | { allowed: false; code: "report_export_disabled"; httpStatus: 403 } + | { allowed: false; code: "report_generation_in_progress"; httpStatus: 409 } + | { allowed: false; code: "report_rate_limited"; httpStatus: 429 } +>; + +export type PersonalReportEntitlementDeps = Readonly<{ + userId: string; + featureEnabled: boolean; + dailyLimit: number; + countGenerating: (userId: string) => Promise; + countCreatedToday: (userId: string) => Promise; +}>; + +export async function checkPersonalReportEntitlement( + deps: PersonalReportEntitlementDeps, +): Promise { + if (!deps.featureEnabled) { + return { allowed: false, code: "report_export_disabled", httpStatus: 403 }; + } + const generating = await deps.countGenerating(deps.userId); + if (generating > 0) { + return { allowed: false, code: "report_generation_in_progress", httpStatus: 409 }; + } + const createdToday = await deps.countCreatedToday(deps.userId); + if (createdToday >= deps.dailyLimit) { + return { allowed: false, code: "report_rate_limited", httpStatus: 429 }; + } + return { allowed: true }; +} diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts new file mode 100644 index 00000000..7fc3e1e4 --- /dev/null +++ b/frontend/src/lib/personal-report-generation.ts @@ -0,0 +1,1215 @@ +import { createHash } from "node:crypto"; +import { + computeEvidenceHash, + safeParseServerReportDocument, +} from "./personal-report-contract.server-core.ts"; +import type { + EvidenceAppendix, + ReportDocumentV1, +} from "./personal-report-contract.ts"; +import type { + EvidenceRefStatus, + PersonalReportAgentOutput, + ReportAgentPort, + ReportEvidencePacket, + ReportPlanetFact, +} from "@/mastra/personal-report"; +import upstreamSourceManifest from "../../../references/upstream/yinduzhanxing/source-manifest.json"; +// 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"; + +/** + * Personal report generation: workflow evidence -> minimal packet -> report + * agent -> candidate document -> deterministic guard -> canonical server + * parse. + * + * Contract and persistence are the canonical shared modules (p3): + * - `personal-report-contract.ts` (isomorphic) + `personal-report-contract.server-core.ts` + * / `personal-report-contract.server.ts` (server hash + parse entry). + * - `personal-report-service-core.ts` / `personal-report-service.ts` + * (persistence, fingerprint idempotency). + * + * This module never duplicates the schema and never falls back to + * mock/example/random/sample data. Missing real evidence fails closed. + * Stable API codes live in the dependency-free ./personal-report-codes.ts. + */ + +// --------------------------------------------------------------------------- +// Stable failure codes live in ./personal-report-codes.ts (imported above). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Canonical serialization + fingerprints +// --------------------------------------------------------------------------- + +export function canonicalSerialize(value: unknown): string { + if (value === undefined) return "null"; + if (Array.isArray(value)) { + return `[${value.map(canonicalSerialize).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const source = value as Record; + const keys = Object.keys(source).sort(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${canonicalSerialize(source[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function sha256Hex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +const sha256Pattern = /^[0-9a-f]{64}$/; +const sha1Pattern = /^[0-9a-f]{40}$/; + +/** + * Canonical request fingerprint for idempotency. Represents ONLY the request + * payload (reportType, presentationMode, sorted/deduped themes, sessionId, + * chartProfileId). requestId is deliberately excluded: (user_id, request_id) + * is already the unique key and the fingerprint exists solely to detect a + * different payload under the same requestId (409 report_request_conflict). + */ +export function computeRequestFingerprint(input: Readonly<{ + reportType: string; + presentationMode: string; + themes: readonly string[]; + sessionId: string | null; + chartProfileId: string | null; +}>): string { + return sha256Hex(canonicalSerialize({ + reportType: input.reportType, + presentationMode: input.presentationMode, + themes: [...new Set(input.themes)].sort(), + sessionId: input.sessionId, + chartProfileId: input.chartProfileId, + })); +} + +// --------------------------------------------------------------------------- +// Skill snapshot provenance (real server-side value, never "unknown") +// --------------------------------------------------------------------------- + +export type SkillSnapshot = Readonly<{ + sha256: string; + sourceCommit: string | null; +}>; + +export class SkillSnapshotUnavailableError extends Error { + readonly code = "calculation_unavailable"; + + constructor(reason: string) { + super(`Skill snapshot unavailable: ${reason}`); + this.name = "SkillSnapshotUnavailableError"; + } +} + +/** + * Static upstream import manifest (packaged at build time; Docker copies + * references/ into the image). Only the skill snapshot fields are read. + */ +const upstreamManifest = upstreamSourceManifest as Readonly<{ + skill_sha256?: string; + source_commit?: string | null; +}>; + +let cachedSkillSnapshot: SkillSnapshot | null = null; + +/** + * Real skill snapshot provenance, in order: + * 1. env pin JYOTISH_SKILL_SNAPSHOT_SHA256 (validated 64-hex); + * 2. the statically packaged upstream source-manifest skill_sha256. + * sourceCommit is the validated env pin JYOTISH_SKILL_SOURCE_COMMIT, falling + * back to the manifest source_commit when it is a valid 40-hex sha. When no + * valid sha is available this THROWS — reports must never carry a hashed + * sentinel pretending to be a real snapshot. + */ +export function resolveSkillSnapshot(): SkillSnapshot { + if (cachedSkillSnapshot) return cachedSkillSnapshot; + const envSha = process.env.JYOTISH_SKILL_SNAPSHOT_SHA256?.trim(); + const envCommit = process.env.JYOTISH_SKILL_SOURCE_COMMIT?.trim(); + const manifestSha = typeof upstreamManifest.skill_sha256 === "string" + ? upstreamManifest.skill_sha256 + : null; + const manifestCommit = typeof upstreamManifest.source_commit === "string" + ? upstreamManifest.source_commit + : null; + + const sha = envSha && sha256Pattern.test(envSha) + ? envSha + : manifestSha && sha256Pattern.test(manifestSha) + ? manifestSha + : null; + if (!sha) { + throw new SkillSnapshotUnavailableError( + "no valid env pin and no valid static manifest skill_sha256", + ); + } + const sourceCommit = envCommit && sha1Pattern.test(envCommit) + ? envCommit + : manifestCommit && sha1Pattern.test(manifestCommit) + ? manifestCommit + : null; + cachedSkillSnapshot = { sha256: sha, sourceCommit }; + return cachedSkillSnapshot; +} + +// --------------------------------------------------------------------------- +// Allowlist evidence packet builder (workflow response -> minimal packet) +// --------------------------------------------------------------------------- + +type JsonRecord = Record; + +function record(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function booleanValue(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(text).filter((item): item is string => item !== null); +} + +const SIGNS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +] as const; + +const SIGN_INDEX = new Map(SIGNS.map((sign, index) => [sign, index])); +const SIGN_INDEX_CN = new Map([ + ["白羊座", 0], ["金牛座", 1], ["双子座", 2], ["巨蟹座", 3], ["狮子座", 4], ["处女座", 5], + ["天秤座", 6], ["天蝎座", 7], ["射手座", 8], ["摩羯座", 9], ["水瓶座", 10], ["双鱼座", 11], +]); + +function signIndex(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 11) return value; + if (typeof value === "string") { + return SIGN_INDEX.get(value) ?? SIGN_INDEX_CN.get(value) ?? null; + } + return null; +} + +/** + * Mirrors the orchestrator's base_chart selection exactly: + * modules.chart (dict) -> chart_data.chart (nested) -> chart_data itself. + */ +function resolveBaseChart(chartData: JsonRecord): JsonRecord { + const modules = record(chartData.modules); + const modulesChart = modules ? record(modules.chart) : null; + if (modulesChart) return modulesChart; + const nested = record(chartData.chart); + if (nested) return nested; + return chartData; +} + +/** + * Planets accept the real engine's object map ({Sun: {...}, ...}) and the + * legacy array shape. Absolute longitude comes from degree_raw / longitude_deg + * / lon / absolute_degree / degree (the engine's planet degree is the absolute + * 0-360 longitude; degree_in_sign is the in-sign offset and is NOT used here). + * Entries without a complete fact set are skipped (allowlist of full facts). + */ +function readPlanets(value: unknown): ReportPlanetFact[] { + const planets: ReportPlanetFact[] = []; + const entries: Readonly<[string, unknown]>[] = Array.isArray(value) + ? value.map((item, index) => [String(index), item] as const) + : Object.entries(record(value) ?? {}); + for (const [key, item] of entries) { + const row = record(item); + if (!row) continue; + const id = text(row?.id ?? row?.name ?? row?.planet) ?? key; + const sign = text(row?.sign ?? row?.sign_name); + const degree = finiteNumber( + row?.degree_raw ?? row?.longitude_deg ?? row?.lon ?? row?.absolute_degree ?? row?.degree, + ); + if (!sign || degree === null) continue; + planets.push({ + id, + sign, + degree, + house: finiteNumber(row?.house ?? row?.house_number), + retrograde: booleanValue(row?.retrograde ?? row?.is_retrograde), + }); + } + return planets; +} + +/** + * Houses accept the real engine's object map ({house_1: {cusp_sign, ...}}) and + * the legacy array shape ({number, sign}). The real map has NO whole-sign + * `sign` field (only Placidus cusp_sign); house signs are therefore derived + * deterministically from the ascendant sign + house number (whole-sign, + * matching the engine's own whole-sign planet-house numbering) and marked + * signDerived. Array entries that carry a real sign keep it. Occupants are + * filled from planet whole-sign house numbers. + */ +function readHouses( + value: unknown, + ascendantSignIndex: number | null, + planets: readonly ReportPlanetFact[], +): ReportEvidencePacket["chart"]["houses"] { + const rows: Readonly<{ number: number; sign: string | null }>[] = []; + if (Array.isArray(value)) { + for (const item of value) { + const row = record(item); + const number = finiteNumber(row?.number ?? row?.house ?? row?.index ?? row?.house_number); + if (number === null) continue; + rows.push({ number, sign: text(row?.sign ?? row?.sign_name) }); + } + } else { + const map = record(value) ?? {}; + for (const [key, item] of Object.entries(map)) { + const number = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null); + if (!number) continue; + const parsed = Number.parseInt(number, 10); + if (parsed < 1 || parsed > 12) continue; + const row = record(item); + rows.push({ number: parsed, sign: row ? text(row.sign ?? row.sign_name) : null }); + } + } + const houses: ReportEvidencePacket["chart"]["houses"] = rows.map((row) => { + const realSign = row.sign; + const derivedSign = ascendantSignIndex !== null + ? SIGNS[((ascendantSignIndex + row.number - 1) % 12 + 12) % 12] + : null; + const sign = realSign ?? derivedSign; + if (!sign) return { number: row.number, sign: "", signDerived: true, occupants: [] }; + return { + number: row.number, + sign, + signDerived: realSign === null, + occupants: planets + .filter((planet) => planet.house === row.number) + .map((planet) => planet.id) + .slice(0, 12), + }; + }); + return houses; +} + +/** + * Divisional charts in the real engine live under modules.varga_full with keys + * D9_Navamsa / D10_Dasamsa (or D9 / D10). Each varga is {Ascendant: {sign_idx| + * sign}, : {sign_idx|sign}, _meta, _dignity, ...} — there are no house + * arrays. House signs are derived whole-sign from the divisional ascendant and + * occupants from each planet's sign index (the same derivation the engine uses + * for D11). Nothing is fabricated; missing varga data simply omits the chart. + */ +function readVargaHouses( + vargaFull: JsonRecord | null, +): ReportEvidencePacket["chart"]["vargaHouses"] { + if (!vargaFull) return []; + const result: { id: "D9" | "D10"; houses: ReportEvidencePacket["chart"]["houses"] }[] = []; + const variants: Readonly> = { + D9: ["D9_Navamsa", "D9"], + D10: ["D10_Dasamsa", "D10"], + }; + for (const [id, keys] of Object.entries(variants) as ReadonlyArray) { + const varga = keys + .map((key) => record(vargaFull[key])) + .find((entry): entry is JsonRecord => entry !== null); + if (!varga) continue; + const ascendant = record(varga.Ascendant); + const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; + if (ascIndex === null) continue; + const occupants: string[][] = Array.from({ length: 12 }, () => []); + for (const [name, item] of Object.entries(varga)) { + if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; + const row = record(item); + const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; + if (planetIndex === null) continue; + const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; + occupants[house - 1].push(name); + } + const houses: ReportEvidencePacket["chart"]["houses"] = Array.from( + { length: 12 }, + (_, index) => ({ + number: index + 1, + sign: SIGNS[((ascIndex + index) % 12 + 12) % 12], + signDerived: true, + occupants: occupants[index].slice(0, 12), + }), + ); + result.push({ id, houses }); + } + return result; +} + +function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] { + if (!Array.isArray(rows)) return null; + const periods: { lord: string; start: string; end: string }[] = []; + for (const item of rows) { + const row = record(item); + const lord = text(row?.lord ?? row?.planet ?? row?.name); + const start = text(row?.start ?? row?.start_date); + const end = text(row?.end ?? row?.end_date); + if (lord && start && end) periods.push({ lord, start, end }); + } + return periods.length > 0 ? periods : null; +} + +function readVimshottari(chart: JsonRecord | null): ReportEvidencePacket["chart"]["vimshottari"] { + const dasha = record(chart?.dasha); + if (!dasha) return null; + const mahadashas = Array.isArray(dasha.mahadashas) ? dasha.mahadashas : null; + return mahadashas ? readDashaPeriods(mahadashas) : null; +} + +function readNarayana(modules: JsonRecord | null): ReportEvidencePacket["chart"]["narayana"] { + const narayana = record(modules?.narayana_dasha); + if (!narayana) return null; + const rows = Array.isArray(narayana.periods) ? narayana.periods + : Array.isArray(narayana.mahadashas) ? narayana.mahadashas : null; + return rows ? readDashaPeriods(rows) : null; +} + +/** + * machine_evidence_packet.sections is an object map in the real engine + * ({D1: {status: "used"|"missing", source_path}, planet_degrees: {...}, ...}); + * the legacy array shape is also accepted. + */ +function readSections(machinePacket: JsonRecord | null): { + name: string; + status: string; + sourcePath: string; +}[] { + const raw = machinePacket?.sections; + const entries = Array.isArray(raw) + ? raw.map((item, index) => [String(index), item] as const) + : Object.entries(record(raw) ?? {}); + const sections: { name: string; status: string; sourcePath: string }[] = []; + for (const [key, item] of entries) { + const row = record(item) ?? {}; + sections.push({ + name: text(row?.name ?? row?.technique) ?? key, + status: text(row?.status) ?? "unknown", + sourcePath: text(row?.source_path) ?? "", + }); + } + return sections; +} + +/** + * Deterministic evidence-status rule for machine-packet sections. The real + * engine only emits used/missing; "verified" as a literal string must never be + * required or the gate would always fail. Core calculation sections (D1, + * planet_degrees, house_degrees) with an internal source path map to verified; + * everything else internal is partial; external oracle sections are capped at + * partial; missing/blocked stay blocked. + */ +const VERIFIED_CALCULATION_SECTIONS = new Set(["D1", "planet_degrees", "house_degrees"]); +const EXTERNAL_SECTIONS = new Set([ + "external_oracle_status", + "vedastro_official_raw_response", + "vedastro_official_raw_archive_manifest", +]); + +function sectionEvidenceStatus( + status: string, + name: string, + sourcePath: string, +): "verified" | "partial" | "blocked" { + if (status === "missing" || status === "blocked") return "blocked"; + if (status === "verified") return "verified"; + if (status === "partial") return "partial"; + // used / available / received_unverified / unknown / undefined + const internal = sourcePath.startsWith("chart.") + || sourcePath.startsWith("modules.") + || sourcePath.startsWith("scripts."); + if (VERIFIED_CALCULATION_SECTIONS.has(name) && internal) return "verified"; + if (EXTERNAL_SECTIONS.has(name) || sourcePath.startsWith("vedastro_")) return "partial"; + return "partial"; +} + +export type BuildEvidencePacketInput = Readonly<{ + workflow: unknown; + subject: ReportEvidencePacket["subject"]; + requestedThemes: readonly string[]; + reportType: "personal_full" | "personal_thematic"; + presentationMode: "default" | "research"; + candidateRange: Readonly<{ start: string; end: string }> | null; + skillSnapshot: SkillSnapshot; +}>; + +/** Raised when the real workflow evidence cannot support an honest report. */ +export class ReportEvidenceInsufficientError extends Error { + readonly code = "calculation_unavailable"; + + constructor(reason: string) { + super(`Report evidence insufficient: ${reason}`); + this.name = "ReportEvidenceInsufficientError"; + } +} + +function assertUsablePacket(packet: ReportEvidencePacket): void { + if (!packet.chart.ascendant) { + throw new ReportEvidenceInsufficientError("ascendant_missing"); + } + const houseNumbers = packet.chart.houses.map((house) => house.number); + const unique = new Set(houseNumbers); + if (houseNumbers.length !== 12 || unique.size !== 12 + || houseNumbers.some((number) => number < 1 || number > 12)) { + throw new ReportEvidenceInsufficientError("d1_houses_incomplete"); + } + if (packet.chart.planets.length === 0) { + throw new ReportEvidenceInsufficientError("planets_missing"); + } + if (packet.chart.planets.some((planet) => planet.house === null || planet.retrograde === null)) { + throw new ReportEvidenceInsufficientError("planet_fact_incomplete"); + } + if (packet.evidenceRefs.length === 0) { + throw new ReportEvidenceInsufficientError("evidence_refs_missing"); + } + // At least one ref must be backed by an explicit verified fact; pure layer + // names (partial) are not enough to claim evidence-backed sections. + if (!packet.evidenceRefs.some((ref) => ref.status === "verified")) { + throw new ReportEvidenceInsufficientError("no_verified_evidence_ref"); + } +} + +/** + * Extracts ONLY allowlisted facts from the real workflow response. Internal + * paths, prompts, exception stacks, chat history and unrelated raw objects are + * structurally excluded: unknown keys are never copied. Fails closed when the + * evidence cannot support a report. + */ +export function buildReportEvidencePacket(input: BuildEvidencePacketInput): ReportEvidencePacket { + const workflow = record(input.workflow) ?? {}; + const chartData = record(workflow.chart) ?? {}; + const modules = record(chartData.modules) ?? {}; + const consumerContext = record(workflow.consumer_context) ?? {}; + const machinePacket = record(workflow.machine_evidence_packet) ?? {}; + const answerPolicy = record(consumerContext.answer_policy) ?? {}; + + // Real base chart selection mirrors the orchestrator: modules.chart (dict) + // -> chart_data.chart (nested) -> chart_data itself. Houses fall back to the + // top-level chart like the orchestrator's house_degrees section does. + const baseChart = resolveBaseChart(chartData); + + const ascendant = record(baseChart.ascendant); + const ascendantSign = text(ascendant?.sign ?? ascendant?.sign_name); + const ascendantSignIndex = ascendantSign ? signIndex(ascendantSign) : null; + const ascendantDegree = finiteNumber( + ascendant?.degree_in_sign ?? ascendant?.degree ?? ascendant?.longitude_deg ?? ascendant?.lon, + ); + + const planets = readPlanets(baseChart.planets); + const houses = readHouses( + baseChart.houses ?? chartData.houses, + ascendantSignIndex, + planets, + ); + const vimshottari = readVimshottari(baseChart); + const narayana = readNarayana(modules); + const vargaHouses = readVargaHouses(record(modules.varga_full)); + + const availableLayers = stringArray(consumerContext.available_layers); + const missingLayers = stringArray(consumerContext.missing_route_layers); + const hardBlockers = stringArray(consumerContext.hard_blockers); + + const techniqueAudit: { technique: string; status: string; note: string }[] = []; + const seenTechniques = new Set(); + const sections = readSections(machinePacket); + const sectionsByName = new Map(sections.map((section) => [section.name, section])); + // Layer names alone are route availability, not verified facts: they map to + // partial at best. + for (const technique of [...availableLayers, ...missingLayers, ...hardBlockers]) { + if (!technique || seenTechniques.has(technique)) continue; + seenTechniques.add(technique); + const status = hardBlockers.includes(technique) + ? "blocked" + : missingLayers.includes(technique) + ? "missing" + : "available"; + techniqueAudit.push({ + technique, + status, + note: status === "missing" + ? "not computed for this route" + : status === "blocked" + ? "hard blocker" + : "", + }); + } + // Machine-packet sections (object map in the real engine, array accepted). + // The section name is the technique key; status comes from section.status + // via the deterministic sectionEvidenceStatus rule. + for (const section of sections) { + if (seenTechniques.has(section.name)) continue; + seenTechniques.add(section.name); + techniqueAudit.push({ + technique: section.name, + status: section.status, + note: section.sourcePath ? `source: ${section.sourcePath}` : "", + }); + } + + const blockedTechniques = hardBlockers.length > 0 + ? hardBlockers + : techniqueAudit.filter((row) => row.status === "blocked").map((row) => row.technique); + + const conflicts: { techniques: string[]; summary: string }[] = []; + const rawConflicts = Array.isArray(machinePacket.conflicts) + ? machinePacket.conflicts + : Array.isArray(consumerContext.conflicts) + ? consumerContext.conflicts + : []; + for (const item of rawConflicts) { + const row = record(item); + const summary = text(row?.summary ?? row?.message ?? row?.description); + const techniques = stringArray(row?.techniques ?? row?.layers); + if (summary) conflicts.push({ techniques, summary }); + } + + const evidenceRefs: { id: string; technique: string; status: EvidenceRefStatus }[] = []; + techniqueAudit.forEach((row, index) => { + const section = sectionsByName.get(row.technique); + const status = section + ? sectionEvidenceStatus(section.status, section.name, section.sourcePath) + : canonicalTechniqueStatus(row.status); + evidenceRefs.push({ + id: `ev-audit-${index + 1}`, + technique: row.technique, + status, + }); + }); + conflicts.forEach((conflict, index) => { + evidenceRefs.push({ + id: `ev-conflict-${index + 1}`, + technique: conflict.techniques.join("+") || "conflict", + status: "blocked", + }); + }); + + const deterministicForbidden = stringArray(answerPolicy.deterministic_claims_forbidden_for); + const canAnswerPreciseTiming = answerPolicy.can_answer_precise_timing === true; + + const calculationFacts = { + ascendant: ascendantSign && ascendantDegree !== null + ? { sign: ascendantSign, degree: ascendantDegree } + : null, + planets, + houses, + vimshottari, + narayana, + }; + const engineHash = text(baseChart.result_hash) ?? text(chartData.result_hash) ?? text(machinePacket.calculation_hash); + const calculationHash = engineHash && sha256Pattern.test(engineHash) + ? engineHash + : sha256Hex(canonicalSerialize(calculationFacts)); + + const packet: ReportEvidencePacket = { + schemaVersion: "report_evidence_packet.v1", + subject: input.subject, + requestedThemes: [...input.requestedThemes], + reportType: input.reportType, + presentationMode: input.presentationMode, + chart: { + calculationHash, + calculationHashDerived: !(engineHash && sha256Pattern.test(engineHash)), + ascendant: ascendantSign && ascendantDegree !== null + ? { sign: ascendantSign, degree: ascendantDegree } + : null, + planets, + houses, + vimshottari, + narayana, + vargaHouses, + }, + techniqueAudit, + conflicts, + blockedTechniques: [...new Set(blockedTechniques)], + evidenceRefs, + candidateRange: input.candidateRange, + answerPolicy: { + canAnswerPreciseTiming: canAnswerPreciseTiming && input.candidateRange === null, + deterministicClaimsForbiddenFor: [...new Set(deterministicForbidden)], + }, + skillSnapshotSha256: input.skillSnapshot.sha256, + skillSourceCommit: input.skillSnapshot.sourceCommit, + }; + assertUsablePacket(packet); + return packet; +} + +export function canonicalTechniqueStatus(status: string): "verified" | "partial" | "blocked" { + if (status === "verified") return "verified"; + // "available" is only a route-layer name, never verified evidence. + if (status === "partial" || status === "available" || status === "unknown") return "partial"; + return "blocked"; +} + +function techniqueSlug(name: string, fallback: string): string { + const slug = name.toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80); + return /^[a-z0-9_.-]{1,80}$/.test(slug) ? slug : fallback; +} + +// --------------------------------------------------------------------------- +// Document assembly (deterministic; agent writes narrative only) +// --------------------------------------------------------------------------- + +export const PERSONAL_REPORT_DISCLAIMER = + "本报告基于所提供出生信息与服务器计算的排盘证据生成,属于解释性参考,不构成医疗、法律或投资建议。出生时间未经确认时,报告中的时间相关表述仅为方向性参考。"; + +export type AssembleReportDocumentInput = Readonly<{ + reportId: string; + generatedAt: string; + packet: ReportEvidencePacket; + agentOutput: PersonalReportAgentOutput; +}>; + +function canonicalChartHouses( + houses: readonly ReportEvidencePacket["chart"]["houses"][number][], +): ReportDocumentV1["charts"][number]["houses"] { + return houses.map((house) => ({ + houseNumber: house.number, + sign: house.sign, + occupants: [...house.occupants].slice(0, 12), + })); +} + +function canonicalPlanets( + planets: readonly ReportPlanetFact[], +): ReportDocumentV1["charts"][number]["planets"] { + return planets.map((planet) => ({ + name: planet.id, + sign: planet.sign, + longitudeDegrees: planet.degree, + houseNumber: planet.house as number, + retrograde: planet.retrograde as boolean, + })); +} + +/** + * Builds the candidate ReportDocument v1. Order is strict: appendix first, + * then evidenceHash = computeEvidenceHash(appendix) (canonical server hash, + * never a model self-report), then the final document. + */ +export function assembleReportDocument( + input: AssembleReportDocumentInput, +): ReportDocumentV1 { + const { packet } = input; + if (!packet.chart.ascendant) { + throw new ReportEvidenceInsufficientError("ascendant_missing"); + } + + const techniqueAudit: EvidenceAppendix["techniqueAudit"] = packet.techniqueAudit.map( + (row, index) => ({ + id: `ev-audit-${index + 1}`, + techniqueId: techniqueSlug(row.technique, `tech-${index + 1}`), + techniqueName: row.technique, + status: canonicalTechniqueStatus(row.status), + used: canonicalTechniqueStatus(row.status) === "verified", + ...(row.note ? { notes: row.note.slice(0, 500) } : {}), + }), + ); + + const conflicts: EvidenceAppendix["conflicts"] = packet.conflicts.map((conflict, index) => ({ + id: `ev-conflict-${index + 1}`, + description: conflict.summary.slice(0, 1000), + impact: "多技法结果不一致,相关结论已按确定性边界降级", + status: "unresolved", + })); + + const calculationEvidence: EvidenceAppendix["calculationEvidence"] = []; + if (packet.chart.calculationHashDerived) { + calculationEvidence.push({ + id: "ev-calc-derived", + label: "calculation_hash", + value: packet.chart.calculationHash, + source: "derived_server_sha256_over_allowlisted_calculation_facts", + }); + } + if (packet.chart.houses.some((house) => house.signDerived) + || packet.chart.vargaHouses.some((varga) => varga.houses.some((house) => house.signDerived))) { + calculationEvidence.push({ + id: "ev-calc-house-signs", + label: "house_sign_derivation", + value: "whole_sign_from_ascendant_for_houses_without_a_source_sign", + source: "server_derived", + }); + } + calculationEvidence.push({ + id: "ev-calc-ascendant", + label: "ascendant", + value: `${packet.chart.ascendant.sign} ${packet.chart.ascendant.degree.toFixed(2)}°`, + source: "server_calculation", + }); + packet.chart.vimshottari?.forEach((period, index) => { + calculationEvidence.push({ + id: `ev-calc-vimshottari-${index + 1}`, + label: `Vimshottari 大运:${period.lord}`, + value: `${period.start} – ${period.end}`, + source: "server_calculation", + }); + }); + packet.chart.narayana?.forEach((period, index) => { + calculationEvidence.push({ + id: `ev-calc-narayana-${index + 1}`, + label: `Narayana 大运:${period.lord}`, + value: `${period.start} – ${period.end}`, + source: "server_calculation", + }); + }); + + const appendix: EvidenceAppendix = { + expandedByDefault: false, + techniqueAudit, + conflicts, + calculationEvidence, + blockedTechniques: packet.blockedTechniques + .map((technique) => technique.slice(0, 120)) + .slice(0, 100), + }; + const evidenceHash = computeEvidenceHash(appendix); + + const charts: ReportDocumentV1["charts"] = [{ + id: "D1", + title: "本命盘 D1", + houses: canonicalChartHouses(packet.chart.houses), + planets: canonicalPlanets(packet.chart.planets), + claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference", + }]; + for (const varga of packet.chart.vargaHouses) { + if (varga.houses.length === 0) continue; + charts.push({ + id: varga.id, + title: varga.id === "D9" ? "九分盘 D9" : "事业盘 D10", + houses: canonicalChartHouses(varga.houses), + claimStatus: "single_system_inference", + }); + } + + const document: ReportDocumentV1 = { + schemaVersion: "report_document.v1", + reportId: input.reportId, + reportType: packet.reportType, + presentationMode: packet.presentationMode, + generatedAt: input.generatedAt, + subject: { + displayName: packet.subject.displayName, + birthTimeStatus: packet.subject.birthTimeStatus, + birthPlaceLabel: packet.subject.birthPlaceLabel, + }, + provenance: { + skillSourceCommit: packet.skillSourceCommit, + skillSnapshotSha256: packet.skillSnapshotSha256, + calculationHash: packet.chart.calculationHash, + evidenceHash, + reportContractVersion: "1", + }, + executiveSummary: { + headline: input.agentOutput.executiveSummary.headline, + summary: input.agentOutput.executiveSummary.summary, + priorities: [...input.agentOutput.executiveSummary.priorities], + overallClaimStatus: input.agentOutput.thematicNarrative.some( + (section) => section.claimStatus === "blocked", + ) + ? "blocked" + : "single_system_inference", + }, + charts, + thematicNarrative: input.agentOutput.thematicNarrative.map((section) => ({ + id: section.id, + title: section.title, + narrative: section.narrative, + actions: [...section.actions], + caveats: [...section.caveats], + claimStatus: section.claimStatus, + evidenceRefs: [...section.evidenceRefs], + })), + evidenceAppendix: appendix, + disclaimer: PERSONAL_REPORT_DISCLAIMER, + }; + return document; +} + +// --------------------------------------------------------------------------- +// Deterministic post-generation guard +// --------------------------------------------------------------------------- + +export const PRECISE_TIMING_PATTERNS: readonly RegExp[] = [ + /20\d{2}\s*年\s*[0-90-9一二三四五六七八九十]{1,2}\s*月(?:\s*[0-90-9一二三四五六七八九十]{1,2}\s*日)?/, + /[0-90-9一二三四五六七八九十]{1,2}\s*月\s*[0-90-9一二三四五六七八九十]{1,2}\s*日/, + /(?:今年|明年|后年|本月|下月)\s*[0-90-9一二三四五六七八九十]{1,2}\s*月/, + /(?:今年|明年|后年)\s*(?:上旬|中旬|下旬)/, +]; + +export const MEDICAL_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:得|患|染)(?:上)?(?:癌症|肿瘤|心脏病|糖尿病|绝症|重病|白血病)/, + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:不孕|流产|难产|残疾|瘫痪|失明|早逝|夭折|猝死)/, + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:治愈|康复|痊愈|好转)/, + /必死|必生男|必生女|必然不孕|命中注定(?:会)?(?:死|得病)/, +]; + +export const LEGAL_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ + /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:胜诉|败诉|无罪|获释|判刑|坐牢|诉讼成功|官司(?:能|会)?赢)/, +]; + +export const INVESTMENT_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ + /(?:必定|一定会|肯定会|必然|百分之百|保证|稳|必)\s*(?:会|能|将)?\s*(?:赚钱|盈利|回本|大涨|暴涨|涨停|翻倍|暴富|亏光)/, + /稳赚不赔|必涨|必跌|保本保息|包赚/, +]; + +export type ForbiddenClaimDomain = "medical" | "legal" | "investment" | "timing"; + +export type ForbiddenClaim = Readonly<{ domain: ForbiddenClaimDomain; matchedText: string }>; + +export function findForbiddenDeterministicClaims(textValue: string): ForbiddenClaim[] { + const claims: ForbiddenClaim[] = []; + const scan = (domain: ForbiddenClaimDomain, patterns: readonly RegExp[]) => { + for (const pattern of patterns) { + const match = textValue.match(pattern); + if (match) claims.push({ domain, matchedText: match[0] }); + } + }; + scan("timing", PRECISE_TIMING_PATTERNS); + scan("medical", MEDICAL_DETERMINISTIC_PATTERNS); + scan("legal", LEGAL_DETERMINISTIC_PATTERNS); + scan("investment", INVESTMENT_DETERMINISTIC_PATTERNS); + return claims; +} + +export function splitSentences(textValue: string): string[] { + return textValue + .split(/(?<=[。!?!?;;])\s*|\n+/u) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +export type RedactionResult = Readonly<{ text: string; removedCount: number }>; + +/** + * Removes sentences that match the given forbidden patterns. Deterministic + * and locale-independent: sentence splitting is punctuation/newline based. + */ +export function redactDeterministicSentences( + textValue: string, + patterns: readonly RegExp[], +): RedactionResult { + const sentences = splitSentences(textValue); + const kept: string[] = []; + let removedCount = 0; + for (const sentence of sentences) { + if (patterns.some((pattern) => pattern.test(sentence))) { + removedCount += 1; + } else { + kept.push(sentence); + } + } + return { text: kept.join(""), removedCount }; +} + +const BLOCKED_SECTION_CAVEAT = "该部分证据受限,已按确定性边界降级,仅保留方向性描述。"; + +type GuardSection = { + id: string; + narrative: string; + claimStatus: string; + evidenceRefs: string[]; + caveats: string[]; +}; + +export type ReportGuardReadModel = { + executiveSummary: { headline: string; summary: string; overallClaimStatus: string }; + sections: GuardSection[]; +}; + +/** Structural projection of a parsed document for guard purposes only. */ +export function projectReportGuardReadModel(document: unknown): ReportGuardReadModel | null { + const root = record(document); + if (!root) return null; + const executiveSummary = record(root.executiveSummary); + if (!executiveSummary) return null; + const headline = text(executiveSummary.headline); + const summary = text(executiveSummary.summary); + const overallClaimStatus = text(executiveSummary.overallClaimStatus); + if (!headline || !summary || !overallClaimStatus) return null; + if (!Array.isArray(root.thematicNarrative)) return null; + const sections: GuardSection[] = []; + for (const item of root.thematicNarrative) { + const row = record(item); + const id = text(row?.id); + const narrative = text(row?.narrative); + const claimStatus = text(row?.claimStatus); + if (!id || !narrative || !claimStatus) return null; + sections.push({ + id, + narrative, + claimStatus, + evidenceRefs: stringArray(row?.evidenceRefs), + caveats: stringArray(row?.caveats), + }); + } + if (sections.length === 0) return null; + return { executiveSummary: { headline, summary, overallClaimStatus }, sections }; +} + +export type GuardResult = + | { ok: true; document: D } + | { ok: false; code: "report_guard_rejected"; reason: string }; + +function effectiveClaimStatus( + claimed: string, + refs: readonly ReportEvidencePacket["evidenceRefs"][number][], +): string { + if (refs.length === 0) return "blocked"; + const refById = new Map(refs.map((ref) => [ref.id, ref])); + let hasBlocked = false; + for (const sectionRef of refs) { + if (!refById.has(sectionRef.id) || refById.get(sectionRef.id)!.status === "blocked") { + hasBlocked = true; + } + } + if (hasBlocked && refs.every((ref) => refById.get(ref.id)?.status === "blocked")) { + return "blocked"; + } + if (hasBlocked) return "parameter_sensitive"; + return claimed; +} + +/** + * Deterministic post-generation guard. It never invents data; it only + * downgrades, redacts, or rejects. Runs BEFORE the final canonical server + * parse, so every mutation is re-validated by the contract. + */ +export function applyReportGuard( + document: D, + packet: ReportEvidencePacket, +): GuardResult { + const readModel = projectReportGuardReadModel(document); + if (!readModel) { + return { ok: false, code: "report_guard_rejected", reason: "report_document_unreadable" }; + } + + // 1. evidenceRefs existence: every section ref must exist in the packet + // (the packet refs ARE the canonical appendix ids). + const packetRefIds = new Set(packet.evidenceRefs.map((ref) => ref.id)); + for (const section of readModel.sections) { + for (const ref of section.evidenceRefs) { + if (!packetRefIds.has(ref)) { + return { ok: false, code: "report_guard_rejected", reason: `unresolved_evidence_ref:${ref}` }; + } + } + } + + const next = structuredClone(document) as JsonRecord; + const narrative = Array.isArray(next.thematicNarrative) ? next.thematicNarrative : []; + const summary = record(next.executiveSummary); + const timingBlocked = !packet.answerPolicy.canAnswerPreciseTiming; + + const summaryPriorities = Array.isArray(summary?.priorities) ? summary.priorities : []; + const summaryTextForScan = [ + readModel.executiveSummary.summary, + ...summaryPriorities.map(String), + ].join("\n"); + + // 2. Medical / legal / investment deterministic claims are never shippable. + for (const section of readModel.sections) { + const row = narrative.find((item) => record(item)?.id === section.id); + const target = record(row); + const sectionText = [ + text(target?.narrative) ?? section.narrative, + ...(Array.isArray(target?.actions) ? target.actions.map(String) : []), + ...(Array.isArray(target?.caveats) ? target.caveats.map(String) : []), + ].join("\n"); + const hardDomain = findForbiddenDeterministicClaims(sectionText).find( + (claim) => claim.domain !== "timing", + ); + if (hardDomain) { + return { + ok: false, + code: "report_guard_rejected", + reason: `deterministic_${hardDomain.domain}_claim`, + }; + } + } + if (findForbiddenDeterministicClaims(summaryTextForScan).some((claim) => claim.domain !== "timing")) { + return { + ok: false, + code: "report_guard_rejected", + reason: "deterministic_claim_in_summary", + }; + } + if (findForbiddenDeterministicClaims(readModel.executiveSummary.headline).length > 0) { + return { + ok: false, + code: "report_guard_rejected", + reason: "deterministic_claim_in_headline", + }; + } + + // 3. Precise timing restriction: redact future precise timing from sections + // and summary, downgrade affected sections to blocked. + const step3Blocked = new Set(); + if (timingBlocked) { + for (const section of readModel.sections) { + const row = narrative.find((item) => record(item)?.id === section.id); + const target = record(row); + if (!target) continue; + const redacted = redactDeterministicSentences( + typeof target.narrative === "string" ? target.narrative : "", + PRECISE_TIMING_PATTERNS, + ); + let changed = false; + if (redacted.removedCount > 0) { + target.narrative = redacted.text; + changed = true; + } + if (Array.isArray(target.actions)) { + const keptActions = target.actions + .map(String) + .filter((action) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(action))); + if (keptActions.length !== target.actions.length) { + target.actions = keptActions; + changed = true; + } + } + if (Array.isArray(target.caveats)) { + const keptCaveats = target.caveats + .map(String) + .filter((caveat) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(caveat))); + if (keptCaveats.length !== target.caveats.length) { + target.caveats = keptCaveats; + changed = true; + } + } + if (changed) { + step3Blocked.add(section.id); + target.claimStatus = "blocked"; + const caveats = stringArray(target.caveats); + if (!caveats.includes(BLOCKED_SECTION_CAVEAT)) caveats.push(BLOCKED_SECTION_CAVEAT); + target.caveats = caveats; + } + } + const summaryRedacted = redactDeterministicSentences( + readModel.executiveSummary.summary, + PRECISE_TIMING_PATTERNS, + ); + if (summary) { + if (summaryRedacted.removedCount > 0) { + summary.summary = summaryRedacted.text; + summary.overallClaimStatus = "blocked"; + } + if (Array.isArray(summary.priorities)) { + const keptPriorities = summary.priorities + .map(String) + .filter((priority) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(priority))); + if (keptPriorities.length !== summary.priorities.length) { + summary.priorities = keptPriorities; + } + } + } + } + + // 4. Blocked downgrade from evidence refs: a section whose refs are all + // blocked must be blocked; any blocked ref caps the section at + // parameter_sensitive. Blocked sections must not keep deterministic + // phrasing of any domain. If every section ends blocked, the whole + // report is blocked. + const refStatuses = new Map(packet.evidenceRefs.map((ref) => [ref.id, ref.status])); + let blockedSectionCount = 0; + for (const section of readModel.sections) { + const row = narrative.find((item) => record(item)?.id === section.id); + const target = record(row); + if (!target) continue; + const sectionRefs = section.evidenceRefs.map((ref) => ( + refStatuses.has(ref) ? packet.evidenceRefs.find((candidate) => candidate.id === ref)! : null + )).filter((ref): ref is ReportEvidencePacket["evidenceRefs"][number] => ref !== null); + target.claimStatus = step3Blocked.has(section.id) + ? "blocked" + : effectiveClaimStatus(section.claimStatus, sectionRefs); + if (target.claimStatus === "blocked") blockedSectionCount += 1; + const finalStatus = target.claimStatus as string; + if (finalStatus === "blocked") { + const narrativeText = typeof target.narrative === "string" ? target.narrative : ""; + const blockedClaims = findForbiddenDeterministicClaims(narrativeText); + const hardDomain = blockedClaims.find((claim) => claim.domain !== "timing"); + if (hardDomain) { + return { + ok: false, + code: "report_guard_rejected", + reason: `deterministic_${hardDomain.domain}_claim_in_blocked_section`, + }; + } + const redacted = redactDeterministicSentences(narrativeText, PRECISE_TIMING_PATTERNS); + if (redacted.removedCount > 0) { + target.narrative = redacted.text; + } + } + } + if (summary && blockedSectionCount === readModel.sections.length) { + summary.overallClaimStatus = "blocked"; + } + + return { ok: true, document: next as D }; +} + +// --------------------------------------------------------------------------- +// Generation pipeline: agent -> document -> guard -> canonical server parse +// --------------------------------------------------------------------------- + +export type GeneratePersonalReportDeps = Readonly<{ + reportId: string; + packet: ReportEvidencePacket; + agent: ReportAgentPort; + now?: () => Date; +}>; + +export type GeneratePersonalReportResult = Readonly< + | { status: "ready"; document: ReportDocumentV1; evidenceHash: string } + | { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" } +>; + +/** + * Runs the dedicated report agent exactly once (plus its single internal + * repair retry), assembles the candidate document, applies the deterministic + * guard, then runs the FINAL canonical server parse on the guarded document. + * The evidence hash is recomputed from the canonical evidence appendix by the + * server contract — never a model self-report. Never falls back to mock, + * example, random or sample data. + */ +export async function generatePersonalReport( + deps: GeneratePersonalReportDeps, +): Promise { + const agentOutput = await deps.agent.generate(deps.packet); + const candidate = assembleReportDocument({ + reportId: deps.reportId, + generatedAt: (deps.now ?? (() => new Date()))().toISOString(), + packet: deps.packet, + agentOutput, + }); + const guarded = applyReportGuard(candidate, deps.packet); + if (!guarded.ok) { + return { status: "failed", failureCode: "report_guard_rejected" }; + } + const parsed = safeParseServerReportDocument(guarded.document); + if (!parsed.ok) { + return { status: "failed", failureCode: "report_schema_invalid" }; + } + return { + status: "ready", + document: parsed.document, + evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), + }; +} diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts new file mode 100644 index 00000000..cbb1962c --- /dev/null +++ b/frontend/src/lib/personal-report-route-core.ts @@ -0,0 +1,508 @@ +/** + * Personal report route handlers as a dependency-injected core (no + * next/server, no network, no model): fully executable in unit tests with + * fakes. The API routes are thin adapters that resolve the real production + * dependencies (authenticated Supabase reads, admin-backed persistence, + * existing auth/profile/workflow) and map the returned { status, body } to + * NextResponse. + * + * Ownership is enforced twice: the authenticated client/RLS scopes reads and + * deletes, and the persistence service scopes every query by userId. + */ + +import { z } from "zod"; +import type { ConsultationInput } from "@/mastra"; +import type { + ReportAgentPort, + ReportEvidencePacket, +} from "@/mastra/personal-report"; +import { + buildReportEvidencePacket, + computeRequestFingerprint, + generatePersonalReport, + type GeneratePersonalReportResult, + type SkillSnapshot, +} from "./personal-report-generation"; +import { REPORT_STABLE_CODES } from "./personal-report-codes"; +import { checkSameOrigin } from "./personal-report-entitlement"; +import type { + CreateGeneratingInput, + CreateGeneratingResult, + PersonalReportRecord, + PersonalReportService, +} from "./personal-report-service-core"; + +const reportRequestThemes = z.enum(["career", "marriage", "wealth", "timing", "general"]); + +export const personalReportCreateRequestSchema = z.object({ + requestId: z.string().uuid(), + sessionId: z.string().uuid().nullable().optional(), + chartProfileId: z.string().uuid().nullable().optional(), + reportType: z.enum(["personal_full", "personal_thematic"]), + presentationMode: z.enum(["default", "research"]).default("default"), + themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]), +}).strict(); + +export type PersonalReportCreateRequest = z.infer; + +export type ReportRouteResponse = Readonly<{ status: number; body: Record }>; + +export type ReportServicePort = Pick< + PersonalReportService, + | "getByUserAndRequestId" + | "createGenerating" + | "completeReady" + | "markFailed" + | "getOwnedById" + | "deleteOwned" +>; + +type JsonRecord = Record; + +function record(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function text(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +const USABLE_BIRTH_TIME_STATUSES = new Set(["accepted", "confirmed"]); + +function parseClockMinutes(value: unknown): { hour: number; minute: number } | null { + const clock = text(value); + if (!clock) return null; + const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(clock); + if (!match) return null; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { hour, minute }; +} + +function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null { + const date = text(value); + if (!date) return null; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) return null; + const year = Number.parseInt(match[1], 10); + const month = Number.parseInt(match[2], 10); + const day = Number.parseInt(match[3], 10); + if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null; + return { year, month, day }; +} + +export function reportView(row: PersonalReportRecord) { + return { + id: row.id, + requestId: row.requestId, + reportType: row.reportType, + presentationMode: row.presentationMode, + status: row.status, + failureCode: row.failureCode, + createdAt: row.createdAt, + completedAt: row.completedAt, + }; +} + +function replayOrConflict( + existing: PersonalReportRecord, + fingerprint: string, +): ReportRouteResponse { + if (existing.requestFingerprint !== fingerprint) { + return { + status: 409, + body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict }, + }; + } + if (existing.status === "ready") { + return { + status: 200, + body: { report: reportView(existing), reportDocument: existing.reportDocument }, + }; + } + if (existing.status === "generating") { + return { + status: 409, + body: { error: "该报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress }, + }; + } + // A failed record is never silently resurrected: surface the stable + // failure. Retrying requires a new requestId. + return { status: 200, body: { report: reportView(existing) } }; +} + +export type ReportCreateCoreDeps = Readonly<{ + requestUrl: string; + origin: string | null; + allowedOrigins: readonly string[]; + userId: string | null; + rawBody: unknown; + profile: unknown | null; + checkSessionOwned: (sessionId: string) => Promise; + checkChartProfileOwned: (chartProfileId: string) => Promise; + featureEnabled: boolean; + dailyLimit: number; + counts: Readonly<{ + countGenerating: () => Promise; + countCreatedToday: () => Promise; + }>; + persistence: ReportServicePort; + model: Readonly<{ id: string }> | null; + runWorkflow: (input: ConsultationInput) => Promise; + createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort; + skillSnapshot: SkillSnapshot; + now?: () => Date; +}>; + +export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise { + // Same-origin gate first (CSRF), then auth. + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + if (!originDecision.ok) { + return { + status: 403, + body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (!deps.userId) { + return { status: 401, body: { error: "请先登录" } }; + } + const userId = deps.userId; + + const parsed = personalReportCreateRequestSchema.safeParse(deps.rawBody); + if (!parsed.success) { + return { + status: 400, + body: { error: "报告请求格式不正确", code: REPORT_STABLE_CODES.invalidRequest }, + }; + } + const payload = parsed.data; + + // Profile truth + birth status. Missing or unusable profile is 422. + const profile = record(deps.profile); + if (!profile) { + return { + status: 422, + body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete }, + }; + } + const birthTimeStatus = text(profile.birth_time_status); + const activeBirthTime = text(profile.active_birth_time); + const birthDate = parseBirthDate(profile.birth_date); + const birthClock = parseClockMinutes(activeBirthTime); + const latitude = finiteNumber(profile.latitude); + const longitude = finiteNumber(profile.longitude); + const timezoneOffset = finiteNumber(profile.timezone_offset); + const displayName = text(profile.name) ?? "我的报告"; + const birthPlaceLabel = text(profile.birth_place_label) ?? "未知出生地"; + + if (!birthTimeStatus || !USABLE_BIRTH_TIME_STATUSES.has(birthTimeStatus)) { + return { + status: 422, + body: { error: "出生时间尚未达到可用状态", code: REPORT_STABLE_CODES.birthTimeNotUsable }, + }; + } + if (!birthDate || !birthClock || latitude === null || longitude === null + || timezoneOffset === null) { + return { + status: 422, + body: { error: "出生资料不完整,无法生成报告", code: REPORT_STABLE_CODES.birthTimeNotUsable }, + }; + } + + // Session / chart-profile ownership (when provided). + if (payload.sessionId && !(await deps.checkSessionOwned(payload.sessionId))) { + return { + status: 403, + body: { error: "会话不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (payload.chartProfileId && !(await deps.checkChartProfileOwned(payload.chartProfileId))) { + return { + status: 403, + body: { error: "星盘资料不属于当前用户", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + + if (!deps.featureEnabled) { + return { + status: 403, + body: { error: "个人报告功能暂未开放", code: REPORT_STABLE_CODES.exportDisabled }, + }; + } + + // Canonical request fingerprint: payload identity only, requestId excluded. + const fingerprint = computeRequestFingerprint({ + reportType: payload.reportType, + presentationMode: payload.presentationMode, + themes: payload.themes, + sessionId: payload.sessionId ?? null, + chartProfileId: payload.chartProfileId ?? null, + }); + + // Idempotent replay: an existing row with the same fingerprint returns the + // stored state; a different payload under the same requestId is a 409 + // request conflict — never treated as a replay. + const existing = await deps.persistence.getByUserAndRequestId(userId, payload.requestId); + if (existing) { + return replayOrConflict(existing, fingerprint); + } + + const generating = await deps.counts.countGenerating(); + if (generating > 0) { + return { + status: 409, + body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress }, + }; + } + const createdToday = await deps.counts.countCreatedToday(); + if (createdToday >= deps.dailyLimit) { + return { + status: 429, + body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited }, + }; + } + + const createInput: CreateGeneratingInput = { + userId, + requestId: payload.requestId, + requestFingerprint: fingerprint, + reportType: payload.reportType, + presentationMode: payload.presentationMode, + requestedThemes: payload.themes, + sessionId: payload.sessionId ?? null, + chartProfileId: payload.chartProfileId ?? null, + skillSourceCommit: deps.skillSnapshot.sourceCommit, + skillSnapshotSha256: deps.skillSnapshot.sha256, + }; + const begun: CreateGeneratingResult = await deps.persistence.createGenerating(createInput); + if (begun.kind === "generation_in_progress") { + return { + status: 409, + body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress }, + }; + } + if (begun.kind === "request_conflict") { + return { + status: 409, + body: { error: "请求内容与已有记录不一致", code: REPORT_STABLE_CODES.requestConflict }, + }; + } + if (begun.kind === "replayed") { + return replayOrConflict(begun.record, fingerprint); + } + const row = begun.record; + + // Real workflow evidence (main chain), never mock/example/random data. + if (!deps.model) { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.modelUnavailable); + return { + status: 502, + body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable }, + }; + } + + const workflowTheme = payload.reportType === "personal_thematic" + ? payload.themes[0] + : "general"; + const workflowInput: ConsultationInput = { + year: birthDate.year, + month: birthDate.month, + day: birthDate.day, + hour: birthClock.hour, + minute: birthClock.minute, + lat: latitude, + lon: longitude, + tz: timezoneOffset, + city: birthPlaceLabel, + question: `请生成我的个人${payload.reportType === "personal_full" ? "综合" : "主题"}报告(主题:${payload.themes.join("、")})`, + theme: workflowTheme, + entryMode: "direct_chart", + }; + + let workflow: unknown; + try { + workflow = await deps.runWorkflow(workflowInput); + } catch { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 502, + body: { error: "排盘引擎暂不可用", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } + const workflowRecord = record(workflow); + if (!workflowRecord || workflowRecord.success !== true || !record(workflowRecord.chart)) { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 502, + body: { error: "排盘引擎未返回可用星盘", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } + + let packet: ReportEvidencePacket; + try { + packet = buildReportEvidencePacket({ + workflow, + subject: { + displayName, + birthTimeStatus: birthTimeStatus === "confirmed" ? "confirmed" : "accepted", + birthPlaceLabel, + }, + requestedThemes: payload.themes, + reportType: payload.reportType, + presentationMode: payload.presentationMode, + candidateRange: birthTimeStatus === "accepted" + ? { start: activeBirthTime ?? "", end: activeBirthTime ?? "" } + : null, + skillSnapshot: deps.skillSnapshot, + }); + } catch (error) { + // Real evidence could not support an honest report: fail closed, never + // generate an empty or sample-backed report. + if (error instanceof Error && error.name === "ReportEvidenceInsufficientError") { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.calculationUnavailable); + return { + status: 422, + body: { error: "排盘证据不足以生成诚实报告", code: REPORT_STABLE_CODES.calculationUnavailable }, + }; + } + throw error; + } + + const result: GeneratePersonalReportResult = await generatePersonalReport({ + reportId: row.id, + packet, + agent: deps.createAgent(deps.model), + now: deps.now, + }); + + if (result.status === "failed") { + await deps.persistence.markFailed(userId, row.id, result.failureCode); + return { + status: 422, + body: { + error: result.failureCode === REPORT_STABLE_CODES.guardRejected + ? "报告未通过确定性校验" + : "报告内容未通过结构校验", + code: result.failureCode, + }, + }; + } + + try { + const readyRow = await deps.persistence.completeReady(userId, row.id, result.document); + return { + status: 201, + body: { report: reportView(readyRow), reportDocument: readyRow.reportDocument }, + }; + } catch { + await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid); + return { + status: 422, + body: { error: "报告未通过合同校验", code: REPORT_STABLE_CODES.schemaInvalid }, + }; + } +} + +export type ReportReadCoreDeps = Readonly<{ + requestUrl: string; + origin: string | null; + allowedOrigins: readonly string[]; + userId: string | null; + reportId: string; + persistence: Pick; + /** + * Canonical server re-validation of a stored ready document (defense in + * depth: a polluted DB row must never reach the browser). Production wires + * safeParseServerReportDocument; tests inject fakes or the real parser. + */ + validateReadyDocument: ( + document: unknown, + ) => { ok: true; document: unknown } | { ok: false }; +}>; + +export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + if (!originDecision.ok) { + return { + status: 403, + body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (!deps.userId) { + return { status: 401, body: { error: "请先登录" } }; + } + const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId); + if (!row) { + return { + status: 404, + body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + }; + } + if (row.status === "ready") { + // Re-validate the stored document through the canonical server parse + // before it is allowed to leave the server; an invalid stored document is + // surfaced as a stable failure WITHOUT the document body. + const validated = deps.validateReadyDocument(row.reportDocument); + if (!validated.ok) { + return { + status: 422, + body: { + error: "报告内容未通过合同校验", + code: REPORT_STABLE_CODES.schemaInvalid, + report: reportView(row), + }, + }; + } + return { + status: 200, + body: { report: reportView(row), reportDocument: validated.document }, + }; + } + return { status: 200, body: { report: reportView(row) } }; +} + +export type ReportDeleteCoreDeps = Readonly<{ + requestUrl: string; + origin: string | null; + allowedOrigins: readonly string[]; + userId: string | null; + reportId: string; + persistence: Pick; +}>; + +export async function resolveReportDelete(deps: ReportDeleteCoreDeps): Promise { + const originDecision = checkSameOrigin(deps.requestUrl, deps.origin, deps.allowedOrigins); + if (!originDecision.ok) { + return { + status: 403, + body: { error: "跨域请求被拒绝", code: REPORT_STABLE_CODES.resourceForbidden }, + }; + } + if (!deps.userId) { + return { status: 401, body: { error: "请先登录" } }; + } + const row = await deps.persistence.getOwnedById(deps.userId, deps.reportId); + if (!row) { + return { + status: 404, + body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + }; + } + const removed = await deps.persistence.deleteOwned(deps.userId, row.id); + if (!removed) { + return { + status: 404, + body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, + }; + } + return { status: 200, body: { ok: true } }; +} diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts new file mode 100644 index 00000000..2fc42181 --- /dev/null +++ b/frontend/src/mastra/personal-report.ts @@ -0,0 +1,286 @@ +import { Agent } from "@mastra/core/agent"; +import { z } from "zod"; +import type { ResolvedLanguageModel } from "./model"; + +/** + * 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 `ReportEvidencePacket`. Chat history, + * SKILL.md source text, system prompts, tool traces, internal paths and error + * stacks must never reach this agent. + */ + +export type ClaimStatus = + | "multi_system_consensus" + | "single_system_inference" + | "parameter_sensitive" + | "unclosed_divisional_chart" + | "user_history_verification_required" + | "blocked"; + +export const claimStatusSchema = z.enum([ + "multi_system_consensus", + "single_system_inference", + "parameter_sensitive", + "unclosed_divisional_chart", + "user_history_verification_required", + "blocked", +]); + +export type EvidenceRefStatus = "verified" | "partial" | "blocked"; + +export type ReportEvidenceRef = Readonly<{ + /** Canonical appendix id: `ev-audit-` / `ev-conflict-` / `ev-calc-`. */ + id: string; + technique: string; + status: EvidenceRefStatus; +}>; + +export type ReportDashaPeriod = Readonly<{ + lord: string; + start: string; + end: string; +}>; + +export type ReportChartHouse = Readonly<{ + number: number; + sign: string; + /** Whole-sign derivation from the ascendant when the source lacks a sign. */ + signDerived: boolean; + /** Planet names occupying this house (whole-sign house numbers). */ + occupants: readonly string[]; +}>; + +export type ReportVargaHouses = Readonly<{ + id: "D9" | "D10"; + houses: readonly ReportChartHouse[]; +}>; + +export type ReportPlanetFact = Readonly<{ + id: string; + sign: string; + degree: number; + house: number | null; + retrograde: boolean | null; +}>; + +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<{ + technique: string; + status: string; + note: string; + }>[]; + conflicts: readonly Readonly<{ + 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[]; + }>; + 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 const personalReportAgentOutputSchema = z.object({ + executiveSummary: 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([]), + }), + thematicNarrative: z.array( + z.object({ + id: reportSectionIdSchema, + 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), + }), + ).min(1).max(12), +}).strict(); + +export type PersonalReportAgentOutput = z.infer; + +export type PersonalReportAgentTelemetry = Readonly<{ + modelId: string; + outcome: "resolved" | "aborted" | "failed"; + elapsedMs: number; + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + repairAttempted: boolean; +}>; + +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 minimal server-computed evidence packet. Use those facts exclusively. 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 the packet. Never mention server internals, tool names, engine names, providers, skill files, prompts, paths, hashes or any methodology detail unless the packet's technique audit requires disclosure. + +Truth boundaries are hard output contracts: +- A technique listed in blockedTechniques or with audit status blocked/partial in the packet must never be described as used or confirmed. If the packet answerPolicy.canAnswerPreciseTiming is false, give direction and structure only: never state a month, a date, a specific year, or a guaranteed timing outcome. Do not claim certainty or guaranteed outcomes anywhere. +- A candidate birth-time range is not a confirmed birth time. Never present it as confirmed, never pick a midpoint minute, and never give precise timing from it. +- 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. +- Section ids must be the lowercase theme keys (career, marriage, wealth, timing, general, or derived keys such as career_overview). Each thematicNarrative section must reference evidenceRefs using the exact "ev-..." ids listed in the packet's evidenceRefs. Every claim in a section must be traceable to those refs. If a section's evidence is only partial or blocked, choose claimStatus accordingly (blocked when the packet marks the underlying techniques blocked). +- Keep actions concrete and cautious; caveats must state limits honestly. +- Write formal, readable Simplified Chinese for a printed report.`; + +function readUsage(value: unknown) { + const record = value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; + const numberOrNull = (key: string) => ( + typeof record[key] === "number" && Number.isFinite(record[key]) ? record[key] as number : null + ); + return { + inputTokens: numberOrNull("inputTokens"), + outputTokens: numberOrNull("outputTokens"), + totalTokens: numberOrNull("totalTokens"), + }; +} + +export class PersonalReportAgentOutputError extends Error { + readonly code = "report_schema_invalid"; + + constructor() { + 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(packet: ReportEvidencePacket): string { + return `请根据以下唯一的事实包生成个人报告 JSON。只使用该事实包中的内容,严格按输出 schema 返回 JSON。 +${JSON.stringify(packet)}`; +} + +export type ReportAgentPort = Readonly<{ + modelId: string; + generate( + packet: ReportEvidencePacket, + signal?: AbortSignal, + ): Promise; +}>; + +const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。"; + +export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportAgentPort { + const agent = new Agent({ + id: `personal-report-${model.id}`, + name: "Personal Report Writer", + model: model.model, + instructions: personalReportInstructions, + }); + + return { + modelId: model.id, + async generate(packet, signal) { + const startedAt = Date.now(); + const prompt = buildReportPrompt(packet); + let repairAttempted = false; + try { + const first = await agent.generate( + [{ role: "user", content: prompt }], + { + abortSignal: signal, + structuredOutput: { + schema: personalReportAgentOutputSchema, + jsonPromptInjection: "inline", + }, + }, + ); + const firstParsed = personalReportAgentOutputSchema.safeParse(first.object); + if (firstParsed.success) { + logTelemetry(model.id, startedAt, false, "resolved", first.usage); + return firstParsed.data; + } + + // Exactly one repair retry is allowed. A second failure is terminal. + repairAttempted = true; + const repaired = await agent.generate( + [{ role: "user", content: `${prompt}${REPAIR_PROMPT_SUFFIX}` }], + { + abortSignal: signal, + structuredOutput: { + schema: personalReportAgentOutputSchema, + jsonPromptInjection: "inline", + }, + }, + ); + const repairedParsed = personalReportAgentOutputSchema.safeParse(repaired.object); + if (repairedParsed.success) { + logTelemetry(model.id, startedAt, true, "resolved", repaired.usage); + return repairedParsed.data; + } + logTelemetry(model.id, startedAt, true, "failed", repaired.usage); + throw new PersonalReportAgentOutputError(); + } catch (error) { + if (error instanceof PersonalReportAgentOutputError) throw error; + logTelemetry(model.id, startedAt, repairAttempted, "failed", null); + throw error; + } + }, + }; +} + +function logTelemetry( + modelId: string, + startedAt: number, + repairAttempted: boolean, + outcome: "resolved" | "failed", + usage: unknown, +) { + const tokens = readUsage(usage); + const telemetry: PersonalReportAgentTelemetry = { + modelId, + outcome, + elapsedMs: Math.max(0, Date.now() - startedAt), + inputTokens: tokens.inputTokens, + outputTokens: tokens.outputTokens, + totalTokens: tokens.totalTokens, + repairAttempted, + }; + // Telemetry must never include the prompt, the packet, birth data or the + // report body. + console.info("[personal-report-agent]", JSON.stringify(telemetry)); +} diff --git a/frontend/tests/personal-report-api.test.ts b/frontend/tests/personal-report-api.test.ts new file mode 100644 index 00000000..b1ebbf7e --- /dev/null +++ b/frontend/tests/personal-report-api.test.ts @@ -0,0 +1,819 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { computeRequestFingerprint } from "../src/lib/personal-report-generation.ts"; +import { safeParseServerReportDocument } from "../src/lib/personal-report-contract.server-core.ts"; +import { + resolveReportCreate, + resolveReportDelete, + resolveReportRead, + type ReportCreateCoreDeps, + type ReportServicePort, +} from "../src/lib/personal-report-route-core.ts"; +import type { + CreateGeneratingInput, + CreateGeneratingResult, + PersonalReportRecord, +} from "../src/lib/personal-report-service-core.ts"; +import type { ReportAgentPort, PersonalReportAgentOutput } from "../src/mastra/personal-report.ts"; + +const createRoute = readFileSync( + new URL("../src/app/api/reports/route.ts", import.meta.url), + "utf8", +); +const itemRoute = readFileSync( + new URL("../src/app/api/reports/[reportId]/route.ts", import.meta.url), + "utf8", +); +const generationSource = readFileSync( + new URL("../src/lib/personal-report-generation.ts", import.meta.url), + "utf8", +); +const codesSource = readFileSync( + new URL("../src/lib/personal-report-codes.ts", import.meta.url), + "utf8", +); +const coreSource = readFileSync( + new URL("../src/lib/personal-report-route-core.ts", import.meta.url), + "utf8", +); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const UUID_A = "11111111-1111-4111-8111-111111111111"; +const UUID_B = "22222222-2222-4222-8222-222222222222"; +const REPORT_ID = "33333333-3333-4333-8333-333333333333"; +const SESSION_ID = "44444444-4444-4444-8444-444444444444"; + +function chartPayload() { + const planets = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] + .map((name, index) => ({ + id: name, + sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius"][index], + degree: 12.5 + index * 10, + house: index + 1, + retrograde: index === 6, + })); + const houses = Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index], + })); + return { + success: true, + chart: { + ascendant: { sign: "Leo", degree: 12.5 }, + planets, + houses, + dasha: { mahadashas: [{ lord: "Moon", start: "2019-01-01", end: "2029-01-01" }] }, + modules: { varga_full: { d9: { houses } }, narayana_dasha: { periods: [] } }, + }, + consumer_context: { + route: "general", + core_status: "ready", + available_layers: ["D1", "Vimshottari"], + missing_route_layers: [], + hard_blockers: [], + answer_policy: { can_answer_precise_timing: true, deterministic_claims_forbidden_for: [] }, + }, + machine_evidence_packet: { + conflicts: [], + sections: [{ name: "Functional Benefic/Malefic", status: "verified", note: "" }], + }, + }; +} + +function agentOutput(): PersonalReportAgentOutput { + return { + executiveSummary: { + headline: "综合盘面以事业发展为主线", + summary: "事业结构稳定,财富与婚恋需结合分盘审慎解读。", + priorities: ["先聚焦职业方向"], + }, + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "事业层面以十宫与 D10 结构为主,方向性判断稳定。", + actions: ["在稳定领域深耕"], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-2"], + }, + ], + }; +} + +const fakeAgent: ReportAgentPort = { + modelId: "test-model", + async generate() { + return agentOutput(); + }, +}; + +const SKILL_SNAPSHOT = { sha256: "a".repeat(64), sourceCommit: "b".repeat(40) }; + +function profileFixture(overrides: Record = {}) { + return { + name: "测试用户", + birth_date: "1997-08-08", + active_birth_time: "05:30:00", + birth_time_status: "confirmed", + latitude: 39.9, + longitude: 116.4, + timezone_offset: 8, + birth_place_label: "北京", + ...overrides, + }; +} + +class MemoryPersistence implements ReportServicePort { + rows = new Map(); + + constructor(seed: PersonalReportRecord[] = []) { + for (const row of seed) this.rows.set(row.id, row); + } + + record(input: CreateGeneratingInput, id: string, status: "generating" | "failed"): PersonalReportRecord { + return { + id, + userId: input.userId, + sessionId: input.sessionId ?? null, + chartProfileId: input.chartProfileId ?? null, + requestId: input.requestId, + requestFingerprint: input.requestFingerprint, + reportType: input.reportType, + status, + schemaVersion: "report_document.v1", + presentationMode: input.presentationMode, + requestedThemes: input.requestedThemes ?? [], + reportDocument: null, + calculationHash: null, + evidenceHash: null, + skillSourceCommit: input.skillSourceCommit ?? null, + skillSnapshotSha256: input.skillSnapshotSha256, + failureCode: status === "failed" ? "calculation_unavailable" : null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: null, + }; + } + + async getByUserAndRequestId(userId: string, requestId: string) { + for (const row of this.rows.values()) { + if (row.userId === userId && row.requestId === requestId) return row; + } + return null; + } + + async createGenerating(input: CreateGeneratingInput): Promise { + const existing = await this.getByUserAndRequestId(input.userId, input.requestId); + if (existing) { + if (existing.requestFingerprint === input.requestFingerprint) { + return { kind: "replayed", record: existing }; + } + return { kind: "request_conflict", record: existing }; + } + const inFlight = [...this.rows.values()].find( + (row) => row.userId === input.userId && row.status === "generating", + ); + if (inFlight) return { kind: "generation_in_progress", record: inFlight }; + const row = this.record(input, REPORT_ID, "generating"); + this.rows.set(row.id, row); + return { kind: "created", record: row }; + } + + async completeReady(userId: string, reportId: string, document: unknown) { + const row = this.rows.get(reportId); + assert.ok(row && row.userId === userId && row.status === "generating"); + const readyRow: PersonalReportRecord = { + ...row, + status: "ready", + reportDocument: document as PersonalReportRecord["reportDocument"], + completedAt: "2026-08-06T00:01:00.000Z", + }; + this.rows.set(reportId, readyRow); + return readyRow; + } + + async markFailed(userId: string, reportId: string, failureCode: string) { + const row = this.rows.get(reportId); + assert.ok(row && row.userId === userId); + const failedRow: PersonalReportRecord = { + ...row, + status: "failed", + failureCode: failureCode as PersonalReportRecord["failureCode"], + completedAt: "2026-08-06T00:01:00.000Z", + }; + this.rows.set(reportId, failedRow); + return failedRow; + } + + async getOwnedById(userId: string, reportId: string) { + const row = this.rows.get(reportId); + return row && row.userId === userId ? row : null; + } + + async deleteOwned(userId: string, reportId: string) { + const row = this.rows.get(reportId); + if (!row || row.userId !== userId) return false; + this.rows.delete(reportId); + return true; + } +} + +function baseDeps(overrides: Partial = {}): ReportCreateCoreDeps { + const persistence = new MemoryPersistence(); + return { + requestUrl: "https://jyotisha.chat/api/reports", + origin: "https://jyotisha.chat", + allowedOrigins: [], + userId: UUID_A, + rawBody: { + requestId: UUID_B, + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + }, + profile: profileFixture(), + checkSessionOwned: async () => true, + checkChartProfileOwned: async () => true, + featureEnabled: true, + dailyLimit: 5, + counts: { + countGenerating: async () => 0, + countCreatedToday: async () => 0, + }, + persistence, + model: { id: "test-model" }, + runWorkflow: async () => chartPayload(), + createAgent: () => fakeAgent, + skillSnapshot: SKILL_SNAPSHOT, + now: () => new Date("2026-08-06T00:00:00.000Z"), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Executable route core behavior (no network, no model) +// --------------------------------------------------------------------------- + +test("core create: 401 when not logged in", async () => { + const response = await resolveReportCreate(baseDeps({ userId: null })); + assert.equal(response.status, 401); +}); + +test("core create: 403 on cross-origin", async () => { + const response = await resolveReportCreate(baseDeps({ origin: "https://evil.example" })); + assert.equal(response.status, 403); + assert.equal(response.body.code, "report_resource_forbidden"); +}); + +test("core create: 400 on invalid payload", async () => { + const response = await resolveReportCreate(baseDeps({ rawBody: { requestId: "not-a-uuid" } })); + assert.equal(response.status, 400); + assert.equal(response.body.code, "invalid_request"); +}); + +test("core create: 422 profile_incomplete without a profile", async () => { + const response = await resolveReportCreate(baseDeps({ profile: null })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "profile_incomplete"); +}); + +test("core create: 422 birth_time_not_usable for reported status", async () => { + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ birth_time_status: "reported", active_birth_time: "05:30:00" }), + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "birth_time_not_usable"); +}); + +test("core create: 422 birth_time_not_usable for incomplete profile fields", async () => { + const response = await resolveReportCreate(baseDeps({ + profile: profileFixture({ latitude: null, longitude: null }), + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "birth_time_not_usable"); +}); + +test("core create: 403 when the session or chart profile is not owned", async () => { + const response = await resolveReportCreate(baseDeps({ + rawBody: { + requestId: UUID_B, + reportType: "personal_full", + presentationMode: "default", + themes: ["career"], + sessionId: SESSION_ID, + }, + checkSessionOwned: async () => false, + })); + assert.equal(response.status, 403); + assert.equal(response.body.code, "report_resource_forbidden"); +}); + +test("core create: 403 when the feature is disabled", async () => { + const response = await resolveReportCreate(baseDeps({ featureEnabled: false })); + assert.equal(response.status, 403); + assert.equal(response.body.code, "report_export_disabled"); +}); + +test("core create: 409 request conflict for a different payload under the same requestId", async () => { + const fingerprintA = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }); + const fingerprintB = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career"], + sessionId: null, + chartProfileId: null, + }); + assert.notEqual(fingerprintA, fingerprintB); + const seeded = new MemoryPersistence([{ + id: REPORT_ID, + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: UUID_B, + requestFingerprint: fingerprintA, + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"], + calculationHash: "c".repeat(64), + evidenceHash: "d".repeat(64), + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: "2026-08-06T00:00:00.000Z", + }]); + const response = await resolveReportCreate(baseDeps({ + rawBody: { requestId: UUID_B, reportType: "personal_full", presentationMode: "default", themes: ["career"] }, + persistence: seeded, + })); + assert.equal(response.status, 409); + assert.equal(response.body.code, "report_request_conflict"); +}); + +test("core create: 200 replay for a ready row with the same fingerprint", async () => { + const fingerprint = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }); + const seeded = new MemoryPersistence([{ + id: REPORT_ID, + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: UUID_B, + requestFingerprint: fingerprint, + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"], + calculationHash: "c".repeat(64), + evidenceHash: "d".repeat(64), + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: "2026-08-06T00:00:00.000Z", + }]); + const response = await resolveReportCreate(baseDeps({ persistence: seeded })); + assert.equal(response.status, 200); + assert.deepEqual(response.body.reportDocument, { ok: true }); +}); + +test("core create: 409 when a generation is already in progress", async () => { + const generating = new MemoryPersistence(); + generating.rows.set("55555555-5555-4555-8555-555555555555", { + id: "55555555-5555-4555-8555-555555555555", + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: "66666666-6666-4666-8666-666666666666", + requestFingerprint: "e".repeat(64), + reportType: "personal_full", + status: "generating", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: [], + reportDocument: null, + calculationHash: null, + evidenceHash: null, + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: null, + }); + const response = await resolveReportCreate(baseDeps({ persistence: generating })); + assert.equal(response.status, 409); + assert.equal(response.body.code, "report_generation_in_progress"); +}); + +test("core create: 429 at the daily limit", async () => { + const response = await resolveReportCreate(baseDeps({ + counts: { countGenerating: async () => 0, countCreatedToday: async () => 5 }, + })); + assert.equal(response.status, 429); + assert.equal(response.body.code, "report_rate_limited"); +}); + +test("core create: 502 model_unavailable when no model is configured", async () => { + const persistence = new MemoryPersistence(); + const response = await resolveReportCreate(baseDeps({ + model: null, + persistence, + })); + assert.equal(response.status, 502); + assert.equal(response.body.code, "model_unavailable"); + const row = persistence.rows.get(REPORT_ID); + assert.equal(row?.status, "failed"); + assert.equal(row?.failureCode, "model_unavailable"); +}); + +test("core create: 502 calculation_unavailable when the workflow throws", async () => { + const persistence = new MemoryPersistence(); + const response = await resolveReportCreate(baseDeps({ + persistence, + runWorkflow: async () => { + throw new Error("engine down"); + }, + })); + assert.equal(response.status, 502); + assert.equal(response.body.code, "calculation_unavailable"); + assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed"); +}); + +test("core create: 502 when the workflow returns no usable chart", async () => { + const response = await resolveReportCreate(baseDeps({ + runWorkflow: async () => ({ success: false }), + })); + assert.equal(response.status, 502); + assert.equal(response.body.code, "calculation_unavailable"); +}); + +test("core create: 422 when the real evidence cannot support a report (fail closed)", async () => { + const persistence = new MemoryPersistence(); + const workflow = chartPayload() as Record; + const chart = workflow.chart as Record; + chart.houses = (chart.houses as unknown[]).slice(0, 6); + const response = await resolveReportCreate(baseDeps({ + persistence, + runWorkflow: async () => workflow, + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "calculation_unavailable"); + assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed"); +}); + +test("core create: 201 ready with a document on the happy path", async () => { + const persistence = new MemoryPersistence(); + const response = await resolveReportCreate(baseDeps({ persistence })); + assert.equal(response.status, 201); + assert.ok(response.body.reportDocument); + const row = persistence.rows.get(REPORT_ID); + assert.equal(row?.status, "ready"); + assert.ok(row?.reportDocument); +}); + +test("core create: 422 report_guard_rejected when the agent output violates the guard", async () => { + const persistence = new MemoryPersistence(); + const violatingAgent: ReportAgentPort = { + modelId: "test-model", + async generate(): Promise { + return { + executiveSummary: { + headline: "综合盘面", + summary: "结构稳定。", + priorities: [], + }, + thematicNarrative: [{ + id: "career", + title: "事业", + narrative: "你必定会胜诉。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-2"], + }], + }; + }, + }; + const response = await resolveReportCreate(baseDeps({ + persistence, + createAgent: () => violatingAgent, + })); + assert.equal(response.status, 422); + assert.equal(response.body.code, "report_guard_rejected"); + assert.equal(persistence.rows.get(REPORT_ID)?.status, "failed"); +}); + +test("core read: 401 without user, 404 for non-owned or missing reports", async () => { + const persistence = new MemoryPersistence(); + const readyRow = await createReadyRow(persistence); + const unauthenticated = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: null, + reportId: REPORT_ID, + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(unauthenticated.status, 401); + + const otherUser = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_B, + reportId: REPORT_ID, + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(otherUser.status, 404); + + const missing = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: "99999999-9999-4999-8999-999999999999", + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(missing.status, 404); + assert.equal(missing.body.code, "report_not_found"); + assert.equal(readyRow, true); +}); + +test("core read: ready returns the document, generating returns status only", async () => { + const persistence = new MemoryPersistence(); + const row = await createReadyRow(persistence); + const ready = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(ready.status, 200); + assert.ok(ready.body.reportDocument); + assert.equal(row, true); + + const generatingPersistence = new MemoryPersistence(); + generatingPersistence.rows.set(REPORT_ID, { + ...seedRecord(), + status: "generating", + reportDocument: null, + completedAt: null, + }); + const generating = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence: generatingPersistence, + validateReadyDocument: acceptAnyDocument, + }); + assert.equal(generating.status, 200); + assert.equal("reportDocument" in generating.body, false); + assert.equal((generating.body.report as { status: string }).status, "generating"); +}); + +test("core read: rejects a polluted stored ready document via canonical re-validation", async () => { + const persistence = new MemoryPersistence(); + persistence.rows.set(REPORT_ID, { + ...seedRecord(), + reportDocument: { hacked: true } as unknown as PersonalReportRecord["reportDocument"], + }); + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok ? { ok: true, document: parsed.document } : { ok: false }; + }, + }); + assert.equal(response.status, 422); + assert.equal(response.body.code, "report_schema_invalid"); + assert.equal("reportDocument" in response.body, false); + assert.equal((response.body.report as { status: string }).status, "ready"); +}); + +test("core read: returns a legitimate ready document after canonical re-validation", async () => { + const persistence = new MemoryPersistence(); + await createReadyRow(persistence); + const response = await resolveReportRead({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + validateReadyDocument: (document) => { + const parsed = safeParseServerReportDocument(document); + return parsed.ok ? { ok: true, document: parsed.document } : { ok: false }; + }, + }); + assert.equal(response.status, 200); + assert.ok(response.body.reportDocument); +}); + +test("core delete: owner-only, 200 ok for the owner and 404 otherwise", async () => { + const persistence = new MemoryPersistence(); + await createReadyRow(persistence); + const otherUser = await resolveReportDelete({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_B, + reportId: REPORT_ID, + persistence, + }); + assert.equal(otherUser.status, 404); + + const owner = await resolveReportDelete({ + requestUrl: "https://jyotisha.chat/api/reports/x", + origin: null, + allowedOrigins: [], + userId: UUID_A, + reportId: REPORT_ID, + persistence, + }); + assert.equal(owner.status, 200); + assert.deepEqual(owner.body, { ok: true }); + assert.equal(persistence.rows.has(REPORT_ID), false); +}); + +function acceptAnyDocument(document: unknown): { ok: true; document: unknown } | { ok: false } { + return { ok: true, document }; +} + +async function createReadyRow(persistence: MemoryPersistence): Promise { + const fingerprint = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "marriage", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }); + const response = await resolveReportCreate(baseDeps({ persistence })); + if (response.status !== 201) return false; + const row = persistence.rows.get(REPORT_ID); + assert.equal(row?.requestFingerprint, fingerprint); + return true; +} + +function seedRecord(): PersonalReportRecord { + return { + id: REPORT_ID, + userId: UUID_A, + sessionId: null, + chartProfileId: null, + requestId: UUID_B, + requestFingerprint: "f".repeat(64), + reportType: "personal_full", + status: "ready", + schemaVersion: "report_document.v1", + presentationMode: "default", + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportDocument: { ok: true } as unknown as PersonalReportRecord["reportDocument"], + calculationHash: "c".repeat(64), + evidenceHash: "d".repeat(64), + skillSourceCommit: null, + skillSnapshotSha256: "a".repeat(64), + failureCode: null, + createdAt: "2026-08-06T00:00:00.000Z", + updatedAt: "2026-08-06T00:00:00.000Z", + completedAt: "2026-08-06T00:00:00.000Z", + }; +} + +// --------------------------------------------------------------------------- +// Source-level production wiring checks +// --------------------------------------------------------------------------- + +test("POST route uses dual clients: authenticated reads + admin persistence", () => { + assert.match(createRoute, /createServerSupabaseClient\(\)/); + assert.match(createRoute, /\.from\("profiles"\)/); + assert.match(createRoute, /createAdminSupabaseClient\(\)/); + assert.match(createRoute, /createSupabasePersonalReportService\(admin\)/); + assert.match(createRoute, /createPersonalReportDataClient\(admin\)/); + assert.doesNotMatch(createRoute, /resolveReportPersistencePort|resolveReportContractPort/); + assert.doesNotMatch(createRoute, /ReportPersistenceUnavailableError|ReportContractUnavailableError/); + assert.doesNotMatch(createRoute, /not wired yet|尚未就绪/); +}); + +test("GET/DELETE use the authenticated client (least privilege) and the core handlers", () => { + assert.match(itemRoute, /createServerSupabaseClient\(\)/); + assert.match(itemRoute, /createSupabasePersonalReportService\(supabase\)/); + assert.match(itemRoute, /resolveReportRead/); + assert.match(itemRoute, /resolveReportDelete/); + assert.doesNotMatch(itemRoute, /createAdminSupabaseClient/); +}); + +test("route core enforces same-origin and never leaks raw exception text", () => { + assert.match(coreSource, /checkSameOrigin/); + assert.match(coreSource, /REPORT_STABLE_CODES\.resourceForbidden/); + assert.doesNotMatch(createRoute, /error\.message\)/); + assert.doesNotMatch(itemRoute, /error\.message\)/); + assert.doesNotMatch(createRoute, /\.stack/); + assert.doesNotMatch(itemRoute, /\.stack/); +}); + +test("POST route reads the daily limit from env and resolves a real skill snapshot", () => { + assert.match(createRoute, /readPersonalReportDailyLimit\(process\.env\)/); + assert.match(createRoute, /resolveSkillSnapshot\(\)/); + assert.doesNotMatch(createRoute, /每日.*上限.*\d|PERSONAL_REPORT_DAILY_LIMIT.*\?\?\s*["']\d/); +}); + +test("GET route re-validates stored ready documents through the canonical server parse", () => { + assert.match(itemRoute, /safeParseServerReportDocument\(document\)/); + assert.match(itemRoute, /validateReadyDocument/); + assert.match(coreSource, /validateReadyDocument/); + assert.match(coreSource, /canonical server parse/); + assert.match(coreSource, /REPORT_STABLE_CODES\.schemaInvalid/); + assert.doesNotMatch(coreSource, /client.*validation|validate.*client/i); +}); + +test("POST route never generates HTML/PDF/base64 or local paths", () => { + assert.doesNotMatch(createRoute, /window\.print|html2canvas|jsPDF|base64|\.pdf/); + assert.doesNotMatch(createRoute, /sendFile|createWriteStream|\/opt\/|\/var\/|\/Users\//); +}); + +test("stable error codes live in the dependency-free codes module", () => { + for (const code of [ + "profile_incomplete", + "birth_time_not_usable", + "report_generation_in_progress", + "report_rate_limited", + "calculation_unavailable", + "model_unavailable", + "report_schema_invalid", + "report_guard_rejected", + "report_not_found", + "report_request_conflict", + ]) { + assert.ok(codesSource.includes(`"${code}"`), `missing stable code ${code}`); + } + // The codes module must stay free of heavy imports so route handlers that + // only need codes never trace the generation/skill-snapshot logic. + assert.doesNotMatch(codesSource, /node:fs|node:path|node:crypto|readdirSync|readFileSync/); + assert.doesNotMatch(createRoute, /dangerouslySetInnerHTML/); +}); + +test("GET route imports codes from the pure module, never the generation module", () => { + assert.match(itemRoute, /personal-report-codes/); + assert.doesNotMatch(itemRoute, /personal-report-generation/); + assert.doesNotMatch(itemRoute, /resolveSkillSnapshot|buildReportEvidencePacket|canonicalSerialize/); + assert.match(generationSource, /personal-report-codes/); +}); + +test("generation pipeline re-validates with the canonical server parse after the guard", () => { + assert.match(generationSource, /safeParseServerReportDocument\(guarded\.document\)/); + assert.match(generationSource, /assembleReportDocument/); +}); + +test("evidence hash is the canonical appendix hash, never a model self-report", () => { + assert.match(generationSource, /computeEvidenceHash\(parsed\.document\.evidenceAppendix\)/); + assert.match(generationSource, /computeEvidenceHash\(appendix\)/); +}); + +test("skill snapshot resolution fails closed without a real source", () => { + assert.match(generationSource, /SkillSnapshotUnavailableError/); + assert.match(generationSource, /source-manifest\.json/); + assert.doesNotMatch(generationSource, /skill_snapshot_unavailable.*digest/); +}); + +test("generation module has no filesystem/path scanning (static manifest import only)", () => { + assert.doesNotMatch(generationSource, /node:fs|node:path|readdirSync|readFileSync/); + assert.doesNotMatch(generationSource, /\bskillDirectory\b|\brepoRoot\b|turbopackIgnore/); + assert.match(generationSource, /source-manifest\.json/); +}); diff --git a/frontend/tests/personal-report-entitlement.test.ts b/frontend/tests/personal-report-entitlement.test.ts new file mode 100644 index 00000000..0f3baf36 --- /dev/null +++ b/frontend/tests/personal-report-entitlement.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + DEFAULT_PERSONAL_REPORT_DAILY_LIMIT, + REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, + checkPersonalReportEntitlement, + checkSameOrigin, + isPersonalReportFeatureEnabled, + readPersonalReportDailyLimit, + resolveAllowedReportOrigins, +} from "../src/lib/personal-report-entitlement.ts"; + +test("entitlement exposes the report.export.personal capability key", () => { + assert.equal(REPORT_EXPORT_PERSONAL_CAPABILITY_KEY, "report.export.personal"); +}); + +test("feature flag is enabled only by explicit env true", () => { + assert.equal(isPersonalReportFeatureEnabled({}), false); + assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "false" }), false); + assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "TRUE" }), false); + assert.equal(isPersonalReportFeatureEnabled({ PERSONAL_REPORT_ENABLED: "true" }), true); +}); + +test("daily limit is read from env and never hardcoded in the module UI surface", () => { + assert.equal(readPersonalReportDailyLimit({}), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "3" }), 3); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "0" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "-1" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); + assert.equal(readPersonalReportDailyLimit({ PERSONAL_REPORT_DAILY_LIMIT: "abc" }), DEFAULT_PERSONAL_REPORT_DAILY_LIMIT); +}); + +test("allowed origins are parsed from the comma-separated env list", () => { + assert.deepEqual(resolveAllowedReportOrigins({}), []); + assert.deepEqual( + resolveAllowedReportOrigins({ PERSONAL_REPORT_ALLOWED_ORIGINS: " https://a.example ,https://b.example, " }), + ["https://a.example", "https://b.example"], + ); +}); + +test("same-origin check accepts absent origin and same request origin", () => { + assert.deepEqual(checkSameOrigin("https://jyotisha.chat/api/reports", null, []), { ok: true }); + assert.deepEqual( + checkSameOrigin("https://jyotisha.chat/api/reports", "https://jyotisha.chat", []), + { ok: true }, + ); +}); + +test("same-origin check rejects cross-origin and accepts a trusted allowlist", () => { + assert.deepEqual( + checkSameOrigin("https://jyotisha.chat/api/reports", "https://evil.example", []), + { ok: false, code: "cross_origin_forbidden" }, + ); + assert.deepEqual( + checkSameOrigin( + "https://jyotisha.chat/api/reports", + "https://trusted-proxy.example", + ["https://trusted-proxy.example"], + ), + { ok: true }, + ); +}); + +test("entitlement blocks when the feature is disabled", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: false, + dailyLimit: 5, + countGenerating: async () => 0, + countCreatedToday: async () => 0, + }); + assert.deepEqual(result, { allowed: false, code: "report_export_disabled", httpStatus: 403 }); +}); + +test("entitlement blocks a second concurrent generation with 409", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: true, + dailyLimit: 5, + countGenerating: async () => 1, + countCreatedToday: async () => 0, + }); + assert.deepEqual(result, { allowed: false, code: "report_generation_in_progress", httpStatus: 409 }); +}); + +test("entitlement blocks at the daily limit with 429", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: true, + dailyLimit: 2, + countGenerating: async () => 0, + countCreatedToday: async () => 2, + }); + assert.deepEqual(result, { allowed: false, code: "report_rate_limited", httpStatus: 429 }); +}); + +test("entitlement allows a fresh generation within limits", async () => { + const result = await checkPersonalReportEntitlement({ + userId: "u1", + featureEnabled: true, + dailyLimit: 5, + countGenerating: async () => 0, + countCreatedToday: async () => 1, + }); + assert.deepEqual(result, { allowed: true }); +}); + +test("report API routes never hardcode the daily limit in the UI-facing module", () => { + const entitlementSource = readFileSync( + new URL("../src/lib/personal-report-entitlement.ts", import.meta.url), + "utf8", + ); + assert.match(entitlementSource, /REPORT_DAILY_LIMIT_ENV/); + // The limit must be read from env at request time, not baked as a literal + // default inside the route response mapping. + assert.doesNotMatch(entitlementSource, /每日|上限/); +}); diff --git a/frontend/tests/personal-report-generation.test.ts b/frontend/tests/personal-report-generation.test.ts new file mode 100644 index 00000000..cff3bd82 --- /dev/null +++ b/frontend/tests/personal-report-generation.test.ts @@ -0,0 +1,748 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { safeParseServerReportDocument, computeEvidenceHash } from "../src/lib/personal-report-contract.server-core.ts"; +import upstreamSourceManifest from "../../references/upstream/yinduzhanxing/source-manifest.json"; +import { + REPORT_STABLE_CODES, + ReportEvidenceInsufficientError, + applyReportGuard, + assembleReportDocument, + buildReportEvidencePacket, + canonicalSerialize, + computeRequestFingerprint, + findForbiddenDeterministicClaims, + generatePersonalReport, + redactDeterministicSentences, + resolveSkillSnapshot, + sha256Hex, + type SkillSnapshot, +} from "../src/lib/personal-report-generation.ts"; +import { PRECISE_TIMING_PATTERNS } from "../src/lib/personal-report-generation.ts"; +import type { + PersonalReportAgentOutput, + ReportAgentPort, + ReportEvidencePacket, +} from "../src/mastra/personal-report.ts"; + +// --------------------------------------------------------------------------- +// Fixtures: a real-shaped workflow response matching the Python main chain +// (object-map planets/houses/sections, varga_full D9_Navamsa/D10_Dasamsa) +// --------------------------------------------------------------------------- + +function pythonStyleChartPayload() { + const signNames = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]; + const ascIndex = 4; // Leo + const planetsMap: Record> = { + Sun: { sign: "Leo", degree: 142.5, degree_raw: 142.5, degree_in_sign: 22.5, house: 1, retrograde: false, speed: 1.0 }, + Moon: { sign: "Virgo", degree: 172.5, degree_raw: 172.5, degree_in_sign: 22.5, house: 2, retrograde: false, speed: 13.0 }, + Mars: { sign: "Libra", degree: 202.5, degree_raw: 202.5, degree_in_sign: 22.5, house: 3, retrograde: false, speed: 0.6 }, + Mercury: { sign: "Scorpio", degree: 232.5, degree_raw: 232.5, degree_in_sign: 22.5, house: 4, retrograde: true, speed: -0.5 }, + Jupiter: { sign: "Sagittarius", degree: 262.5, degree_raw: 262.5, degree_in_sign: 22.5, house: 5, retrograde: false, speed: 0.2 }, + Venus: { sign: "Capricorn", degree: 292.5, degree_raw: 292.5, degree_in_sign: 22.5, house: 6, retrograde: false, speed: 1.1 }, + Saturn: { sign: "Aquarius", degree: 322.5, degree_raw: 322.5, degree_in_sign: 22.5, house: 7, retrograde: true, speed: -0.1 }, + Rahu: { sign: "Pisces", degree: 352.5, degree_raw: 352.5, degree_in_sign: 22.5, house: 8, retrograde: true, speed: -0.05 }, + Ketu: { sign: "Virgo", degree: 172.5, degree_raw: 172.5, degree_in_sign: 22.5, house: 2, retrograde: true, speed: -0.05 }, + }; + const housesMap: Record> = {}; + for (let index = 0; index < 12; index += 1) { + housesMap[`house_${index + 1}`] = { + cusp_sign: signNames[(ascIndex + index) % 12], + cusp_degree: 140.5 + index * 30, + lord: signNames[(ascIndex + index) % 12], + }; + } + const d9Planets: Record> = { + Sun: { sign: "Leo", sign_idx: 4 }, + Moon: { sign: "Virgo", sign_idx: 5 }, + Mars: { sign: "Cancer", sign_idx: 3 }, + }; + const d10Planets: Record> = { + Sun: { sign: "Taurus", sign_idx: 1 }, + Moon: { sign: "Gemini", sign_idx: 2 }, + }; + return { + success: true, + chart: { + ascendant: { sign: "Leo", degree: 20.5, degree_raw: 140.5, lon: 140.5, sign_cn: "狮子座", lord: "Sun" }, + planets: planetsMap, + houses: housesMap, + dasha: { + mahadashas: [ + { lord: "Moon", start: "2019-01-01", end: "2029-01-01" }, + { lord: "Mars", start: "2029-01-01", end: "2036-01-01" }, + ], + }, + modules: { + varga_full: { + D9_Navamsa: { + _meta: { div: 9 }, + Ascendant: { sign: "Leo", sign_idx: 4 }, + ...d9Planets, + _dignity: {}, + }, + D10_Dasamsa: { + _meta: { div: 10 }, + Ascendant: { sign: "Taurus", sign_idx: 1 }, + ...d10Planets, + }, + }, + narayana_dasha: { + periods: [{ lord: "Sun", start: "2023-01-01", end: "2026-01-01" }], + }, + }, + }, + consumer_context: { + route: "general", + core_status: "ready", + available_layers: ["D1", "D9", "D10", "Vimshottari", "Narayana"], + missing_route_layers: [], + hard_blockers: [], + answer_policy: { + can_answer_precise_timing: true, + deterministic_claims_forbidden_for: [], + }, + }, + machine_evidence_packet: { + conflicts: [ + { techniques: ["Vimshottari", "Narayana"], summary: "两个大运系统给出的阶段边界不一致" }, + ], + sections: { + D1: { status: "used", source_path: "chart.planets+chart.ascendant" }, + D9: { status: "used", source_path: "modules.varga_full.D9" }, + D10: { status: "used", source_path: "modules.varga_full.D10" }, + D2: { status: "missing", source_path: "modules.varga_full.D2" }, + planet_degrees: { status: "used", source_path: "chart.planets" }, + house_degrees: { status: "used", source_path: "chart.houses" }, + dasha_boundaries: { status: "used", source_path: "modules.dasha" }, + narayana_dasha: { status: "used", source_path: "modules.narayana_dasha" }, + external_oracle_status: { status: "used", source_path: "vedastro_official.runtime_truth" }, + vedastro_official_raw_response: { status: "missing", source_path: "vedastro_official.raw_response" }, + }, + }, + }; +} + +function buildPacket(overrides: Partial = {}): ReportEvidencePacket { + const workflow = overrides.workflow ?? pythonStyleChartPayload(); + return buildReportEvidencePacket({ + workflow, + subject: { + displayName: "测试用户", + birthTimeStatus: "confirmed", + birthPlaceLabel: "北京", + }, + requestedThemes: ["career", "marriage", "wealth", "timing"], + reportType: "personal_full", + presentationMode: "default", + candidateRange: null, + skillSnapshot: { sha256: "a".repeat(64), sourceCommit: "b".repeat(40) }, + }); +} + +type BuildPacketOverrides = { + workflow: unknown; +}; + +function agentOutput(overrides: Partial = {}): PersonalReportAgentOutput { + return { + executiveSummary: { + headline: "综合盘面以事业发展为主线", + summary: "命盘显示事业层面具备稳定的结构,财富与婚恋需结合分盘审慎解读。", + priorities: ["先聚焦职业方向", "再核对感情与财富主题"], + }, + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "事业层面以十宫与 D10 结构为主,方向性判断稳定,具体应期需要结合大运边界观察。", + actions: ["在稳定领域深耕"], + caveats: ["该部分为方向性描述"], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1", "ev-audit-3"], + }, + { + id: "marriage", + title: "婚恋", + narrative: "婚恋部分以七宫与 D9 为主,呈现结构特征,不构成确定性结论。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-2"], + }, + ], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Packet builder: allowlist + fail-closed +// --------------------------------------------------------------------------- + +test("packet builder extracts only allowlisted facts, never internal noise", () => { + const workflow = pythonStyleChartPayload(); + (workflow as Record).internal_path = "/opt/app/private/engine.py"; + (workflow as Record).prompt_text = "系统提示词原文"; + (workflow as Record).traceback = "Traceback (most recent call last)"; + (workflow as Record).raw_payload = { anything: true }; + const packet = buildPacket({ workflow }); + const serialized = JSON.stringify(packet); + assert.doesNotMatch(serialized, /internal_path|prompt_text|traceback|raw_payload|\/opt\/app/); + assert.match(serialized, /ev-audit-/); + assert.match(serialized, /ev-conflict-/); +}); + +test("packet builder computes a canonical 64-hex calculation hash and marks derivation", () => { + const packet = buildPacket(); + assert.match(packet.chart.calculationHash, /^[0-9a-f]{64}$/); + assert.equal(packet.chart.calculationHashDerived, true); +}); + +test("packet builder keeps a real engine hash when present", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + chart.result_hash = "c".repeat(64); + const packet = buildPacket({ workflow }); + assert.equal(packet.chart.calculationHash, "c".repeat(64)); + assert.equal(packet.chart.calculationHashDerived, false); +}); + +test("packet builder fails closed without real D1 houses", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const houses = chart.houses as Record; + delete houses["house_9"]; + delete houses["house_10"]; + delete houses["house_11"]; + delete houses["house_12"]; + assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError); +}); + +test("packet builder fails closed without retrograde facts", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const planets = chart.planets as Record>; + planets.Sun.retrograde = undefined; + assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError); +}); + +test("packet builder fails closed without ascendant or evidence refs", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + delete chart.ascendant; + assert.throws(() => buildPacket({ workflow }), ReportEvidenceInsufficientError); + + const workflow2 = pythonStyleChartPayload() as Record; + const consumer = workflow2.consumer_context as Record; + consumer.available_layers = []; + const machine = workflow2.machine_evidence_packet as Record; + machine.sections = {}; + machine.conflicts = []; + assert.throws(() => buildPacket({ workflow: workflow2 }), ReportEvidenceInsufficientError); +}); + +test("no mock fallback: an empty workflow never yields a usable packet", () => { + assert.throws(() => buildPacket({ workflow: {} }), ReportEvidenceInsufficientError); +}); + +test("packet builder normalizes the real Python object-map shapes", () => { + const packet = buildPacket(); + // planets object map -> facts, absolute longitude from degree/degree_raw. + assert.equal(packet.chart.planets.length, 9); + const sun = packet.chart.planets.find((planet) => planet.id === "Sun"); + assert.ok(sun); + assert.equal(sun.sign, "Leo"); + assert.equal(sun.degree, 142.5); + assert.equal(sun.house, 1); + assert.equal(sun.retrograde, false); + assert.equal(packet.chart.planets.find((planet) => planet.id === "Saturn")?.retrograde, true); + // houses object map (cusp_sign only) -> whole-sign derived signs, marked. + assert.equal(packet.chart.houses.length, 12); + assert.equal(packet.chart.houses[0].sign, "Leo"); + assert.equal(packet.chart.houses[0].signDerived, true); + assert.deepEqual(packet.chart.houses[0].occupants, ["Sun"]); + assert.equal(packet.chart.houses[1].sign, "Virgo"); + assert.deepEqual([...packet.chart.houses[1].occupants].sort(), ["Ketu", "Moon"]); + // sections object map -> deterministic statuses: core calculation sections + // verified, internal layers partial, external/missing degraded. + const refs = packet.evidenceRefs; + const verified = refs.filter((ref) => ref.status === "verified").map((ref) => ref.technique); + assert.ok(verified.includes("planet_degrees")); + assert.ok(verified.includes("house_degrees")); + const blocked = refs.filter((ref) => ref.status === "blocked").map((ref) => ref.technique); + assert.ok(blocked.includes("D2")); + assert.ok(blocked.includes("vedastro_official_raw_response")); + const partial = refs.filter((ref) => ref.status === "partial").map((ref) => ref.technique); + assert.ok(partial.includes("dasha_boundaries")); + assert.ok(partial.includes("external_oracle_status")); + // conflicts produce canonical ev-conflict refs. + assert.ok(refs.some((ref) => ref.id.startsWith("ev-conflict-"))); +}); + +test("packet builder resolves the base chart from modules.chart and nested chart", () => { + const base = pythonStyleChartPayload() as Record; + const chartData = base.chart as Record; + // modules.chart wins over nested chart over top level. + const modulesChart = { + ...chartData, + planets: { Sun: { sign: "Aries", degree: 10.5, degree_raw: 10.5, house: 1, retrograde: false } }, + }; + const modules = chartData.modules as Record; + modules.chart = modulesChart; + const viaModules = buildPacket({ workflow: base }); + assert.equal(viaModules.chart.planets.length, 1); + assert.equal(viaModules.chart.planets[0].sign, "Aries"); + delete modules.chart; + + // nested chart_data.chart is the orchestrator's second choice. + const nested = { + ...chartData, + planets: { Moon: { sign: "Pisces", degree: 350.5, degree_raw: 350.5, house: 12, retrograde: false } }, + }; + chartData.chart = nested; + const viaNested = buildPacket({ workflow: base }); + assert.equal(viaNested.chart.planets.length, 1); + assert.equal(viaNested.chart.planets[0].id, "Moon"); +}); + +test("varga houses are whole-sign derived from the divisional ascendant, never fabricated", () => { + const packet = buildPacket(); + const d9 = packet.chart.vargaHouses.find((varga) => varga.id === "D9"); + assert.ok(d9); + assert.equal(d9.houses.length, 12); + // D9 ascendant is Leo (index 4): house 1 Leo, house 2 Virgo. + assert.equal(d9.houses[0].sign, "Leo"); + assert.equal(d9.houses[1].sign, "Virgo"); + // Moon sits in Virgo (index 5) -> whole-sign house 2 of D9. + assert.ok(d9.houses[1].occupants.includes("Moon")); + assert.ok(d9.houses.every((house) => house.signDerived === true)); + const d10 = packet.chart.vargaHouses.find((varga) => varga.id === "D10"); + assert.ok(d10); + // D10 ascendant is Taurus (index 1): house 1 Taurus, house 2 Gemini. + assert.equal(d10.houses[0].sign, "Taurus"); + assert.equal(d10.houses[1].sign, "Gemini"); + assert.ok(d10.houses[1].occupants.includes("Moon")); +}); + +test("array-shaped chart data remains supported", () => { + const workflow = { + success: true, + chart: { + ascendant: { sign: "Leo", degree: 12.5 }, + planets: [ + { id: "Sun", sign: "Leo", degree: 142.5, house: 1, retrograde: false }, + { id: "Moon", sign: "Virgo", degree: 172.5, house: 2, retrograde: false }, + ], + houses: Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][index], + })), + }, + consumer_context: { + route: "general", + core_status: "ready", + available_layers: ["D1"], + missing_route_layers: [], + hard_blockers: [], + answer_policy: { can_answer_precise_timing: true, deterministic_claims_forbidden_for: [] }, + }, + machine_evidence_packet: { + conflicts: [], + sections: [{ name: "planet_degrees", status: "verified", note: "" }], + }, + }; + const packet = buildPacket({ workflow }); + assert.equal(packet.chart.planets.length, 2); + assert.equal(packet.chart.houses.length, 12); + // Array houses carry a real sign: not derived. + assert.equal(packet.chart.houses[0].signDerived, false); + assert.equal(packet.chart.houses[0].sign, "Aries"); + assert.ok(packet.evidenceRefs.some((ref) => ref.status === "verified")); +}); + +test("section status mapping is deterministic (used core sections verified, external degraded)", () => { + const packet = buildPacket(); + const byTechnique = new Map(packet.evidenceRefs.map((ref) => [ref.technique, ref.status])); + assert.equal(byTechnique.get("D1"), "verified"); + assert.equal(byTechnique.get("planet_degrees"), "verified"); + assert.equal(byTechnique.get("house_degrees"), "verified"); + assert.equal(byTechnique.get("dasha_boundaries"), "partial"); + assert.equal(byTechnique.get("external_oracle_status"), "partial"); + assert.equal(byTechnique.get("D2"), "blocked"); + assert.equal(byTechnique.get("vedastro_official_raw_response"), "blocked"); +}); + +// --------------------------------------------------------------------------- +// Fingerprint +// --------------------------------------------------------------------------- + +test("request fingerprint is canonical: sorted, deduped themes, no requestId", () => { + const base = { + reportType: "personal_full", + presentationMode: "default", + themes: ["wealth", "career", "wealth", "timing"], + sessionId: null, + chartProfileId: null, + }; + const fingerprintA = computeRequestFingerprint(base); + const fingerprintB = computeRequestFingerprint({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "timing", "wealth"], + sessionId: null, + chartProfileId: null, + }); + assert.equal(fingerprintA, fingerprintB); + assert.equal(fingerprintA, sha256Hex(canonicalSerialize({ + reportType: "personal_full", + presentationMode: "default", + themes: ["career", "timing", "wealth"], + sessionId: null, + chartProfileId: null, + }))); + const differentType = computeRequestFingerprint({ + ...base, + reportType: "personal_thematic", + }); + assert.notEqual(fingerprintA, differentType); + const withSession = computeRequestFingerprint({ + ...base, + sessionId: "11111111-1111-4111-8111-111111111111", + }); + assert.notEqual(fingerprintA, withSession); +}); + +// --------------------------------------------------------------------------- +// Assembly: canonical contract shape +// --------------------------------------------------------------------------- + +test("assembled document passes the canonical server parse with a D1 of 12 houses", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput(), + }); + const parsed = safeParseServerReportDocument(document); + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.equal(parsed.document.charts.length, 3); + const d1 = parsed.document.charts.find((chart) => chart.id === "D1"); + assert.ok(d1); + assert.equal(d1.houses.length, 12); + assert.deepEqual( + d1.houses.map((house) => house.houseNumber).sort((a, b) => a - b), + Array.from({ length: 12 }, (_, index) => index + 1), + ); + assert.ok(d1.planets && d1.planets.length === 9); + assert.equal(d1.planets[0].retrograde, false); + assert.equal(d1.planets[6].retrograde, true); + // Appendix ids are canonical ev- ids and globally unique. + const ids = [ + ...parsed.document.evidenceAppendix.techniqueAudit.map((row) => row.id), + ...parsed.document.evidenceAppendix.conflicts.map((row) => row.id), + ...parsed.document.evidenceAppendix.calculationEvidence.map((row) => row.id), + ]; + assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.every((id) => /^ev-[a-z0-9_-]{1,63}$/.test(id))); + // evidenceHash matches the canonical recomputation from the appendix. + assert.equal( + parsed.document.provenance.evidenceHash, + computeEvidenceHash(parsed.document.evidenceAppendix), + ); +}); + +test("D9/D10 charts are omitted when no real divisional houses exist", () => { + const workflow = pythonStyleChartPayload() as Record; + const chart = workflow.chart as Record; + const modules = chart.modules as Record; + modules.varga_full = {}; + const packet = buildPacket({ workflow }); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput(), + }); + const parsed = safeParseServerReportDocument(document); + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.deepEqual(parsed.document.charts.map((chartRow) => chartRow.id), ["D1"]); +}); + +// --------------------------------------------------------------------------- +// Deterministic guard +// --------------------------------------------------------------------------- + +test("guard rejects dangling evidence refs", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "稳定结构。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-999"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, false); + if (!guarded.ok) assert.match(guarded.reason, /unresolved_evidence_ref/); +}); + +test("guard redacts precise timing and downgrades the section when timing is blocked", () => { + const packet = buildPacket(); + const timingBlockedPacket = { ...packet, answerPolicy: { ...packet.answerPolicy, canAnswerPreciseTiming: false } }; + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet: timingBlockedPacket, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "方向稳定。2027年3月将迎来事业转折,届时务必把握机会。", + actions: ["2027年3月跳槽"], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, timingBlockedPacket); + assert.equal(guarded.ok, true); + if (!guarded.ok) return; + const section = (guarded.document as unknown as { thematicNarrative: { narrative: string; claimStatus: string; caveats: string[] }[] }) + .thematicNarrative[0]; + assert.doesNotMatch(section.narrative, /2027年3月/); + assert.equal(section.claimStatus, "blocked"); + assert.ok(section.caveats.some((caveat) => caveat.includes("确定性边界"))); +}); + +test("guard rejects medical deterministic claims outright", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "你一定会患上心脏病。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, false); + if (!guarded.ok) assert.match(guarded.reason, /deterministic_medical_claim/); +}); + +test("guard rejects investment deterministic claims hidden in actions", () => { + const packet = buildPacket(); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "wealth", + title: "财富", + narrative: "财富结构稳定。", + actions: ["买入股票必然大涨"], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, false); + if (!guarded.ok) assert.match(guarded.reason, /deterministic_investment_claim/); +}); + +test("guard forces blocked when every evidence ref is blocked", () => { + const workflow = pythonStyleChartPayload() as Record; + const consumer = workflow.consumer_context as Record; + consumer.hard_blockers = ["Narayana"]; + consumer.available_layers = ["D1", "Vimshottari"]; + const packet = buildPacket({ workflow }); + const blockedRef = packet.evidenceRefs.find((ref) => ref.status === "blocked"); + assert.ok(blockedRef); + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet, + agentOutput: agentOutput({ + thematicNarrative: [ + { + id: "timing", + title: "时机", + narrative: "该部分仅保留方向性说明。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: [blockedRef.id], + }, + ], + }), + }); + const guarded = applyReportGuard(document, packet); + assert.equal(guarded.ok, true); + if (!guarded.ok) return; + const section = (guarded.document as unknown as { thematicNarrative: { id: string; claimStatus: string }[] }) + .thematicNarrative[0]; + assert.equal(section.claimStatus, "blocked"); +}); + +test("guard redacts timing from the summary and blocks the report-level status", () => { + const packet = buildPacket(); + const timingBlockedPacket = { ...packet, answerPolicy: { ...packet.answerPolicy, canAnswerPreciseTiming: false } }; + const document = assembleReportDocument({ + reportId: "22222222-2222-4222-8222-222222222222", + generatedAt: "2026-08-06T00:00:00.000Z", + packet: timingBlockedPacket, + agentOutput: agentOutput({ + executiveSummary: { + headline: "综合盘面以事业发展为主线", + summary: "明年3月将迎来关键转折,整体结构稳定。", + priorities: ["先聚焦职业方向"], + }, + }), + }); + const guarded = applyReportGuard(document, timingBlockedPacket); + assert.equal(guarded.ok, true); + if (!guarded.ok) return; + const summary = (guarded.document as unknown as { executiveSummary: { summary: string; overallClaimStatus: string } }) + .executiveSummary; + assert.doesNotMatch(summary.summary, /明年3月/); + assert.equal(summary.overallClaimStatus, "blocked"); +}); + +test("findForbiddenDeterministicClaims and redaction are deterministic", () => { + assert.ok(findForbiddenDeterministicClaims("2027年3月会发生转折").some((claim) => claim.domain === "timing")); + assert.ok(findForbiddenDeterministicClaims("投资必然赚钱").some((claim) => claim.domain === "investment")); + assert.equal(findForbiddenDeterministicClaims("方向性判断稳定").length, 0); + const redacted = redactDeterministicSentences("方向稳定。2027年3月转折。", PRECISE_TIMING_PATTERNS); + assert.equal(redacted.removedCount, 1); + assert.doesNotMatch(redacted.text, /2027年3月/); +}); + +// --------------------------------------------------------------------------- +// Generation pipeline (fake agent, real canonical parse) +// --------------------------------------------------------------------------- + +function fakeAgent(output: PersonalReportAgentOutput, calls: { count: number }): ReportAgentPort { + return { + modelId: "test-model", + async generate() { + calls.count += 1; + return output; + }, + }; +} + +test("generatePersonalReport returns a ready document that passes the server parse", async () => { + const packet = buildPacket(); + const calls = { count: 0 }; + const result = await generatePersonalReport({ + reportId: "22222222-2222-4222-8222-222222222222", + packet, + agent: fakeAgent(agentOutput(), calls), + now: () => new Date("2026-08-06T00:00:00.000Z"), + }); + assert.equal(calls.count, 1); + assert.equal(result.status, "ready"); + if (result.status !== "ready") return; + assert.match(result.evidenceHash, /^[0-9a-f]{64}$/); + const reparsed = safeParseServerReportDocument(result.document); + assert.equal(reparsed.ok, true); +}); + +test("generatePersonalReport fails with report_guard_rejected on guard rejection", async () => { + const packet = buildPacket(); + const result = await generatePersonalReport({ + reportId: "22222222-2222-4222-8222-222222222222", + packet, + agent: fakeAgent(agentOutput({ + thematicNarrative: [ + { + id: "career", + title: "事业", + narrative: "你必定会胜诉。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: ["ev-audit-1"], + }, + ], + }), { count: 0 }), + }); + assert.deepEqual(result, { status: "failed", failureCode: "report_guard_rejected" }); +}); + +test("generatePersonalReport fails with report_schema_invalid when the final parse rejects", async () => { + const workflow = pythonStyleChartPayload() as Record; + const consumer = workflow.consumer_context as Record; + consumer.hard_blockers = ["Narayana"]; + consumer.available_layers = ["D1", "Vimshottari"]; + const packet = buildPacket({ workflow }); + const blockedRef = packet.evidenceRefs.find((ref) => ref.status === "blocked"); + assert.ok(blockedRef); + const result = await generatePersonalReport({ + reportId: "22222222-2222-4222-8222-222222222222", + packet, + agent: fakeAgent(agentOutput({ + thematicNarrative: [ + { + id: "timing", + title: "时机", + narrative: "该部分必然会成功,结构稳定。", + actions: [], + caveats: [], + claimStatus: "single_system_inference", + evidenceRefs: [blockedRef.id], + }, + ], + }), { count: 0 }), + now: () => new Date("2026-08-06T00:00:00.000Z"), + }); + // The guard downgrades the section to blocked (all refs blocked); the + // blocked section still contains the deterministic phrase 必然, so the FINAL + // canonical server parse rejects it. Guard mutations are always re-validated. + assert.equal(result.status, "failed"); + if (result.status === "failed") assert.equal(result.failureCode, "report_schema_invalid"); +}); + +test("skill snapshot is the real packaged manifest sha256, never the literal unknown", async () => { + const snapshot: SkillSnapshot = resolveSkillSnapshot(); + const manifest = upstreamSourceManifest as { skill_sha256?: string }; + assert.match(snapshot.sha256, /^[0-9a-f]{64}$/); + assert.notEqual(snapshot.sha256, "unknown"); + assert.equal(snapshot.sha256, manifest.skill_sha256); + const again: SkillSnapshot = resolveSkillSnapshot(); + assert.equal(snapshot.sha256, again.sha256); +}); + +test("stable codes include the request-conflict mapping", () => { + assert.equal(REPORT_STABLE_CODES.requestConflict, "report_request_conflict"); +});