将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告{pendingDelete.synastry ? `和 ${pendingDelete.synastry} 份合盘` : ""},无法恢复。
diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx
index b21b3d79..6d83bb50 100644
--- a/frontend/src/components/personal-report/personal-report-center.tsx
+++ b/frontend/src/components/personal-report/personal-report-center.tsx
@@ -249,7 +249,7 @@ export function PersonalReportCenter() {
>
{copy.label}
-
{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}
+
{report.reportType === "synastry_full" ? (report.cardSummary || "婚恋合盘") : report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}
{rowMeta(report)}
{note ?
{note}
: null}
{report.status === "failed" ? (
diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx
index 120f8c59..b1f39d27 100644
--- a/frontend/src/components/personal-report/personal-report-page.tsx
+++ b/frontend/src/components/personal-report/personal-report-page.tsx
@@ -19,6 +19,7 @@ import { Clock3, TriangleAlert } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
+import { SynastryReportView } from "@/components/synastry/synastry-report-view";
import { ReportActions } from "./report-actions";
import { ReportFactTables } from "./report-fact-tables";
import { ReportCalculationCharts } from "./report-calculation-charts";
@@ -71,7 +72,8 @@ export type ReportLoadState =
markdownEn?: string;
factTablesEn?: ReportFactTable[];
}
- | { phase: "legacy-unavailable" };
+ | { phase: "legacy-unavailable" }
+ | { phase: "synastry-ready"; reportId: string; synastry: unknown };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
export interface ReportEnvelopeView {
@@ -142,6 +144,13 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const view = json.report as Partial
;
switch (view.status) {
case "ready": {
+ if (view.reportType === "synastry_full" && isRecord(json.synastry)) {
+ return {
+ phase: "synastry-ready",
+ reportId: typeof view.id === "string" ? view.id : "",
+ synastry: json.synastry,
+ };
+ }
const stored = typeof json.longformMarkdown === "string" ? json.longformMarkdown : "";
const markdown = projectOrdinaryReportMarkdown(stored).trim();
if (markdown.length > 0) {
@@ -529,6 +538,16 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
);
}
+ if (state.phase === "synastry-ready") {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
if (state.phase === "legacy-unavailable") {
return (
<>
diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx
new file mode 100644
index 00000000..05110a0e
--- /dev/null
+++ b/frontend/src/components/synastry/synastry-picker.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { FormEvent } from "react";
+
+import { ChartProfileForm } from "@/components/chart-profile-form";
+import { SecondaryHeader } from "@/components/secondary-header";
+import { fetchAccount } from "@/lib/home-cloud-sync";
+import { emptyProfile, type Profile } from "@/lib/home-types";
+import { missingOtherProfileStep, readProfile } from "@/lib/home-profile";
+import { peopleDisplayName } from "@/lib/people-archive-view";
+
+type Person = { id: string; role: "self" | "other"; name: string; incomplete: boolean };
+
+export function SynastryPicker() {
+ const [people, setPeople] = useState([]);
+ const [left, setLeft] = useState("self");
+ const [right, setRight] = useState("");
+ const [adding, setAdding] = useState(false);
+ const [draft, setDraft] = useState(emptyProfile);
+ const [message, setMessage] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ async function load(preferId?: string) {
+ const [account, response] = await Promise.all([
+ fetchAccount(),
+ fetch("/api/chart-profiles", { cache: "no-store" }),
+ ]);
+ if (!response.ok) throw new Error("archive_unavailable");
+ const payload = await response.json() as { profiles?: Array<{ id?: string; profile?: unknown }> };
+ const self = readProfile(account.profile);
+ const rows: Person[] = [
+ { id: "self", role: "self", name: peopleDisplayName(self, "self"), incomplete: missingOtherProfileStep(self) !== null },
+ ...(payload.profiles ?? []).flatMap((row) => {
+ if (!row.id) return [];
+ const profile = readProfile(row.profile);
+ return [{ id: row.id, role: "other" as const, name: peopleDisplayName(profile, "other"), incomplete: missingOtherProfileStep(profile) !== null }];
+ }),
+ ];
+ setPeople(rows);
+ const wanted = preferId || new URLSearchParams(window.location.search).get("with") || "";
+ if (wanted && rows.some((person) => person.id === wanted)) setRight(wanted);
+ }
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => {
+ void load().catch(() => setMessage("星盘档案暂时读不出来"));
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, []);
+
+ async function addPerson(event: FormEvent) {
+ event.preventDefault();
+ if (missingOtherProfileStep(draft)) return;
+ setBusy(true);
+ try {
+ const response = await fetch("/api/chart-profiles", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ role: "other", profile: draft }),
+ });
+ const payload = await response.json().catch(() => null) as { error?: string; profile?: { id?: string } } | null;
+ if (!response.ok || !payload?.profile?.id) {
+ setMessage(payload?.error || "保存失败,请重试");
+ return;
+ }
+ setAdding(false);
+ setDraft(emptyProfile);
+ await load(payload.profile.id);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function createReport() {
+ if (!left || !right || left === right) {
+ setMessage("请选择两个不同的人");
+ return;
+ }
+ setBusy(true);
+ setMessage("");
+ try {
+ const response = await fetch("/api/reports", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ reportType: "synastry_full", subjectA: left, subjectB: right }),
+ });
+ const payload = await response.json().catch(() => null) as { error?: string; report?: { id?: string } } | null;
+ if (!response.ok || !payload?.report?.id) {
+ setMessage(payload?.error || "暂时无法生成合盘报告");
+ return;
+ }
+ window.location.assign(`/reports/${payload.report.id}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
选两个人,生成一份完整合盘报告。
+
+
+
+ {adding ? (
+
void addPerson(event)}
+ onCancel={() => setAdding(false)}
+ submitLabel={busy ? "保存中" : "保存"}
+ submitDisabled={busy || Boolean(missingOtherProfileStep(draft))}
+ />
+ ) : null}
+
+ {message ? {message}
: null}
+
+ >
+ );
+}
diff --git a/frontend/src/components/synastry/synastry-report-view.tsx b/frontend/src/components/synastry/synastry-report-view.tsx
new file mode 100644
index 00000000..dd2b30f6
--- /dev/null
+++ b/frontend/src/components/synastry/synastry-report-view.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useState } from "react";
+
+import { SECTION_TITLES, SYNASTRY_FOOTNOTE, SYNASTRY_PROSE_IDS, TALK_TOGETHER } from "@/lib/synastry-full-sections";
+import type { TechnicalBlock } from "@/lib/synastry-full-technical";
+
+type Reason = { plain?: string; basis?: string; evidence?: string; label?: string };
+type Prose = { id?: string; title?: string; text?: string; reasons?: Reason[] };
+type Segment = { id?: string; phase?: string; text?: string; basis?: string };
+type Practice = { text?: string; evidence?: string };
+
+type LanguageBody = {
+ sections?: Prose[];
+ timeline?: Segment[];
+ practices?: Practice[];
+ technical?: TechnicalBlock[];
+};
+
+type Snapshot = {
+ names?: { a?: string; b?: string };
+ sections?: Prose[];
+ timeline?: Segment[];
+ practices?: Practice[];
+ technical?: TechnicalBlock[];
+ english?: LanguageBody;
+};
+
+function asSnapshot(value: unknown): Snapshot | null {
+ if (!value || typeof value !== "object") return null;
+ return value as Snapshot;
+}
+
+export function SynastryReportView(props: {
+ reportId: string;
+ snapshot: unknown;
+}) {
+ const snapshot = asSnapshot(props.snapshot);
+ const [language, setLanguage] = useState<"zh" | "en">("zh");
+ const [waiting, setWaiting] = useState(false);
+ const [englishNote, setEnglishNote] = useState("");
+ const [english, setEnglish] = useState(snapshot?.english ?? null);
+ const body: LanguageBody | null = snapshot && language === "en" && english ? english : snapshot;
+ if (!snapshot || !body) return null;
+ const names = snapshot.names ?? {};
+ const title = `${names.a || "甲"} × ${names.b || "乙"} 婚恋合盘`;
+
+ async function showEnglish() {
+ if (english) {
+ setLanguage("en");
+ return;
+ }
+ setWaiting(true);
+ setEnglishNote("");
+ try {
+ const created = await fetch(`/api/reports/${props.reportId}/english`, { method: "POST" });
+ if (!created.ok) throw new Error("english_unavailable");
+ const started = Date.now();
+ while (Date.now() - started < 8 * 60 * 1000) {
+ await new Promise((resolve) => window.setTimeout(resolve, 3000));
+ const response = await fetch(`/api/reports/${props.reportId}`, { cache: "no-store" });
+ const payload = await response.json().catch(() => null) as { synastry?: Snapshot } | null;
+ if (payload?.synastry?.english) {
+ setEnglish(payload.synastry.english);
+ setLanguage("en");
+ setWaiting(false);
+ return;
+ }
+ }
+ throw new Error("english_timeout");
+ } catch {
+ setEnglishNote("英文版即将提供");
+ setWaiting(false);
+ }
+ }
+
+ if (waiting) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {title}
+
+
+ {englishNote ? {englishNote}
: null}
+ {SYNASTRY_PROSE_IDS.map((id) => {
+ const section = body.sections?.find((item) => item.id === id);
+ if (!section) return null;
+ return (
+
+ {section.title || SECTION_TITLES[id][language]}
+ {section.text}
+
+ 分析逻辑
+
+ {(section.reasons ?? []).map((reason, index) => (
+ -
+
盘面:{reason.basis}
+ 所以:{reason.plain}
+
+ ))}
+
+
+
+ );
+ })}
+
+ {SECTION_TITLES.timeline[language]}
+
+ {(body.timeline ?? []).map((segment) => (
+ -
+
{segment.phase}
+ {segment.text}
+
+ 分析逻辑
+ 盘面:{segment.basis}
+
+
+ ))}
+
+
+
+ {SECTION_TITLES.practices[language]}
+
+ {(body.practices ?? []).map((item) => - {item.text}
)}
+
+
+
+ 可以一起聊聊
+
+ {TALK_TOGETHER.map((question) => - {question}
)}
+
+
+
+ 技术依据摘要
+ {(snapshot.technical ?? []).map((block) => (
+
+ {block.title}
+
+ {block.lines.map((line) => - {line}
)}
+
+
+ ))}
+
+ {SYNASTRY_FOOTNOTE}
+
+ );
+}
diff --git a/frontend/src/lib/admin/feature-pricing-labels.ts b/frontend/src/lib/admin/feature-pricing-labels.ts
index 68296212..73112be4 100644
--- a/frontend/src/lib/admin/feature-pricing-labels.ts
+++ b/frontend/src/lib/admin/feature-pricing-labels.ts
@@ -3,6 +3,7 @@ export const FEATURE_PRICING_OPTIONS = [
{ value: "chat.premium", label: "高级咨询(chat.premium)" },
{ value: "rectification", label: "生时校正(rectification)" },
{ value: "report.full", label: "完整报告(report.full)" },
+ { value: "synastry.full", label: "完整合盘报告(synastry.full)" },
{ value: "report.export", label: "报告导出(report.export)" },
{ value: "profile.extra", label: "额外档案(profile.extra)" },
] as const;
diff --git a/frontend/src/lib/consultation-billing.ts b/frontend/src/lib/consultation-billing.ts
index e50fc779..cda40234 100644
--- a/frontend/src/lib/consultation-billing.ts
+++ b/frontend/src/lib/consultation-billing.ts
@@ -44,6 +44,7 @@ export type ActualUsage = {
costMicrousd: number;
durationMs: number;
metadata?: {
+ kind?: string;
cache?: {
readTokens: number;
writeTokens: number;
@@ -105,7 +106,7 @@ export async function authorizeUsage(
input: {
userId: string;
requestId: string;
- featureKey: "chat.standard" | "chat.premium" | "rectification" | "report.full" | "report.export" | "profile.extra";
+ featureKey: "chat.standard" | "chat.premium" | "rectification" | "report.full" | "report.export" | "profile.extra" | "synastry.full";
requestedModelId: string;
creditCost: number;
},
diff --git a/frontend/src/lib/marriage-match-service.ts b/frontend/src/lib/marriage-match-service.ts
index 52694463..016a8faf 100644
--- a/frontend/src/lib/marriage-match-service.ts
+++ b/frontend/src/lib/marriage-match-service.ts
@@ -73,6 +73,10 @@ type BirthBody = {
today: string;
};
+export function marriageMatchBirthBody(row: Record, today: string): BirthBody {
+ return engineBirth(row, today);
+}
+
function engineBirth(row: Record, today: string): BirthBody {
const profile = globalBirthProfileFromAccountRow(row);
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? "");
diff --git a/frontend/src/lib/membership.ts b/frontend/src/lib/membership.ts
index 7a763412..aaf92a95 100644
--- a/frontend/src/lib/membership.ts
+++ b/frontend/src/lib/membership.ts
@@ -126,6 +126,7 @@ export function productAudience(product: Pick = {
"chat.standard": "标准 AI 咨询",
"report.full": "完整个人报告",
+ "synastry.full": "完整合盘报告",
rectification: "生时校正",
};
diff --git a/frontend/src/lib/personal-report-service-core.ts b/frontend/src/lib/personal-report-service-core.ts
index fe2b1503..b6178431 100644
--- a/frontend/src/lib/personal-report-service-core.ts
+++ b/frontend/src/lib/personal-report-service-core.ts
@@ -48,7 +48,7 @@ export type PersonalReportFailureCode = (typeof PERSONAL_REPORT_FAILURE_CODES)[n
export const PERSONAL_REPORT_STATUSES = ["generating", "ready", "failed"] as const;
export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number];
-export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
+export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic", "synastry_full"] as const;
export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const;
export const PERSONAL_REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
@@ -77,6 +77,13 @@ export type PersonalReportRecord = Readonly<{
userId: string;
sessionId: string | null;
chartProfileId: string | null;
+ partnerChartProfileId?: string | null;
+ subjectLow?: string | null;
+ subjectHigh?: string | null;
+ fingerprintLow?: string | null;
+ fingerprintHigh?: string | null;
+ synastryRulesVersion?: string | null;
+ synastryReportId?: string | null;
requestId: string;
requestFingerprint: string;
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
@@ -108,6 +115,13 @@ export type CreateGeneratingInput = Readonly<{
requestedThemes?: readonly string[];
sessionId?: string | null;
chartProfileId?: string | null;
+ partnerChartProfileId?: string | null;
+ subjectLow?: string | null;
+ subjectHigh?: string | null;
+ fingerprintLow?: string | null;
+ fingerprintHigh?: string | null;
+ synastryRulesVersion?: string | null;
+ synastryReportId?: string | null;
skillName: string;
skillVersion: string;
skillSourceCommit?: string | null;
@@ -159,6 +173,13 @@ const RECORD_COLUMNS = [
"user_id",
"session_id",
"chart_profile_id",
+ "partner_chart_profile_id",
+ "subject_low",
+ "subject_high",
+ "fingerprint_low",
+ "fingerprint_high",
+ "synastry_rules_version",
+ "synastry_report_id",
"request_id",
"request_fingerprint",
"report_type",
@@ -240,6 +261,13 @@ function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | nu
userId: stringOrNull(row.user_id) ?? "",
sessionId: stringOrNull(row.session_id),
chartProfileId: stringOrNull(row.chart_profile_id),
+ partnerChartProfileId: stringOrNull(row.partner_chart_profile_id),
+ subjectLow: stringOrNull(row.subject_low),
+ subjectHigh: stringOrNull(row.subject_high),
+ fingerprintLow: stringOrNull(row.fingerprint_low),
+ fingerprintHigh: stringOrNull(row.fingerprint_high),
+ synastryRulesVersion: stringOrNull(row.synastry_rules_version),
+ synastryReportId: stringOrNull(row.synastry_report_id),
requestId: stringOrNull(row.request_id) ?? "",
requestFingerprint: stringOrNull(row.request_fingerprint) ?? "",
reportType: row.report_type as PersonalReportRecord["reportType"],
@@ -394,6 +422,13 @@ export function createPersonalReportService(
skill_snapshot_sha256: input.skillSnapshotSha256,
session_id: input.sessionId ?? null,
chart_profile_id: input.chartProfileId ?? null,
+ partner_chart_profile_id: input.partnerChartProfileId ?? null,
+ subject_low: input.subjectLow ?? null,
+ subject_high: input.subjectHigh ?? null,
+ fingerprint_low: input.fingerprintLow ?? null,
+ fingerprint_high: input.fingerprintHigh ?? null,
+ synastry_rules_version: input.synastryRulesVersion ?? null,
+ synastry_report_id: input.synastryReportId ?? null,
skill_source_commit: input.skillSourceCommit ?? null,
created_at: now().toISOString(),
updated_at: now().toISOString(),
diff --git a/frontend/src/lib/personal-report-worker-core.ts b/frontend/src/lib/personal-report-worker-core.ts
index 47df7562..d0b5645d 100644
--- a/frontend/src/lib/personal-report-worker-core.ts
+++ b/frontend/src/lib/personal-report-worker-core.ts
@@ -11,6 +11,7 @@ import {
type PersonalReportService,
} from "./personal-report-service-core.ts";
import type { GeneratePersonalReportResult } from "./personal-report-generation.ts";
+import type { PromptCacheUsage } from "./agent-generation-settings.ts";
import type { PersonalReportSectionService } from "./personal-report-section-service-core.ts";
import type { ReportBillingPort } from "./personal-report-billing.ts";
@@ -45,6 +46,17 @@ export class PersonalReportWorkerError extends Error {
}
}
+export type SynastryPersistedGeneration = Readonly<{
+ status: "synastry_persisted";
+ usage?: Readonly<{
+ inputTokens: number;
+ outputTokens: number;
+ actualModelId?: string;
+ modelConfigVersion?: number;
+ cache?: PromptCacheUsage | null;
+ }>;
+}>;
+
export type PersonalReportWorkerGenerationContext = Readonly<{
report: PersonalReportRecord;
profile: unknown;
@@ -79,7 +91,8 @@ export type PersonalReportWorkerDeps = Readonly<{
loadProfile: (userId: string, subjectId: string) => Promise;
generate: (
context: PersonalReportWorkerGenerationContext,
- ) => Promise;
+ ) => Promise;
+ runIdleSynastryEnglish?: () => Promise;
sectionService?: PersonalReportSectionService;
billing?: ReportBillingPort;
leaseSeconds?: number;
@@ -330,11 +343,13 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
leaseToken: job.leaseToken,
leaseSeconds,
});
- await deps.jobs.updateProgress({
- jobId: job.id,
- leaseToken: job.leaseToken,
- ...PERSONAL_REPORT_WORKER_PROGRESS.generatingReport,
- });
+ if (report.reportType !== "synastry_full") {
+ await deps.jobs.updateProgress({
+ jobId: job.id,
+ leaseToken: job.leaseToken,
+ ...PERSONAL_REPORT_WORKER_PROGRESS.generatingReport,
+ });
+ }
const generated = await deps.generate({
report,
@@ -362,6 +377,30 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
});
await heartbeatChain;
if (heartbeatError !== null) throw heartbeatError;
+ if (generated.status === "synastry_persisted") {
+ report = { ...report, status: "ready" };
+ await deps.jobs.markReady({ jobId: job.id, leaseToken: job.leaseToken });
+ if (deps.billing) {
+ const settlementModelId = generated.usage?.actualModelId?.trim() ?? "";
+ if (!settlementModelId || settlementModelId === "unknown") {
+ throw new PersonalReportWorkerError("model_unavailable", true, "missing settlement model id");
+ }
+ const settled = await deps.billing.complete({
+ userId: job.userId,
+ requestId: job.requestId,
+ usage: {
+ actualModelId: settlementModelId,
+ modelConfigVersion: generated.usage?.modelConfigVersion,
+ inputTokens: generated.usage?.inputTokens ?? 0,
+ outputTokens: generated.usage?.outputTokens ?? 0,
+ durationMs: Math.max(0, now().getTime() - generationStartedAt),
+ ...(generated.usage?.cache ? { cache: generated.usage.cache } : {}),
+ },
+ });
+ if (!settled) throw new PersonalReportWorkerError("report_generation_failed", false, "report billing settlement failed");
+ }
+ return "ready";
+ }
if (generated.status === "failed") {
const innerReason = generated.failureCode === "report_schema_invalid"
? generated.innerReason
@@ -419,7 +458,11 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
async tick(): Promise {
const recovery = await deps.jobs.recoverExpiredLeases(recoveryLimit);
const job = await deps.jobs.claimLease({ workerId: deps.workerId, leaseSeconds });
- if (!job) return { recovery, outcome: "idle", jobId: null };
+ if (!job) {
+ const englishReportId = await deps.runIdleSynastryEnglish?.();
+ if (englishReportId) return { recovery, outcome: "ready", jobId: englishReportId };
+ return { recovery, outcome: "idle", jobId: null };
+ }
const outcome = await processClaimed(job);
return { recovery, outcome, jobId: job.id };
},
diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts
index 69fc0e3f..de71685f 100644
--- a/frontend/src/lib/personal-report-worker.ts
+++ b/frontend/src/lib/personal-report-worker.ts
@@ -32,6 +32,7 @@ import {
import { PERSONAL_REPORT_WRITER_ENABLED } from "@/lib/personal-report-writer-flag";
import { loadReportCandidateRange } from "@/lib/report-candidate-range";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
+import { runIdleSynastryEnglish, runSynastryFullReport } from "@/lib/synastry-full-run";
import { completeUsage, releaseUsage } from "@/lib/consultation-billing";
import type { ConsultationInput } from "@/mastra/consultation-workflow";
import { resolveAyanamsa } from "@/lib/ayanamsa";
@@ -148,6 +149,9 @@ async function generateWriterReport(
let bundle;
try {
+ if (context.report.reportType === "synastry_full") {
+ throw new PersonalReportWorkerError("calculation_unavailable", false);
+ }
bundle = buildReportEvidenceBundleV2({
workflows,
subject: {
@@ -261,15 +265,20 @@ function createProductionWorker(workerId: string) {
complete: async ({ userId, requestId, usage }) => {
const model = (await loadLanguageModelCatalog()).models.find((entry) => entry.id === usage.actualModelId);
if (!model) return false;
+ const reportRow = await admin.from("personal_reports").select("report_type").eq("user_id", userId).eq("request_id", requestId).maybeSingle();
+ const synastry = reportRow.data?.report_type === "synastry_full";
const costMicrousd = Math.round((
usage.inputTokens * (model.inputCostMicrousdPerMillion ?? 0)
+ usage.outputTokens * (model.outputCostMicrousdPerMillion ?? 0)
) / 1_000_000);
const settled = await completeUsage(admin, userId, requestId, {
- eventKey: "report.full", actualModelId: usage.actualModelId,
+ eventKey: synastry ? "synastry.full" : "report.full", actualModelId: usage.actualModelId,
modelConfigVersion: usage.modelConfigVersion, inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens, costMicrousd, durationMs: usage.durationMs,
- ...(usage.cache ? { metadata: { cache: { ...usage.cache, hit: usage.cache.readTokens > 0 } } } : {}),
+ metadata: {
+ ...(synastry ? { kind: "synastry_full" } : {}),
+ ...(usage.cache ? { cache: { ...usage.cache, hit: usage.cache.readTokens > 0 } } : {}),
+ },
});
return settled.success;
},
@@ -277,7 +286,16 @@ function createProductionWorker(workerId: string) {
reserve: async () => { throw new Error("report worker does not reserve usage"); },
},
loadProfile: (userId, subjectId) => loadReportWorkerProfile(admin, userId, subjectId),
+ runIdleSynastryEnglish: () => runIdleSynastryEnglish(admin as never),
generate: async (context) => {
+ if (context.report.reportType === "synastry_full") {
+ return runSynastryFullReport({
+ admin: admin as never,
+ report: context.report,
+ signal: context.signal,
+ onProgress: context.onProgress,
+ });
+ }
const profile = record(context.profile) ?? {};
if (!reportWorkerUsesCandidateRange(context.report, profile)) {
return generateProductionReport(context, null, admin as never);
diff --git a/frontend/src/lib/synastry-full-create.ts b/frontend/src/lib/synastry-full-create.ts
new file mode 100644
index 00000000..997d6562
--- /dev/null
+++ b/frontend/src/lib/synastry-full-create.ts
@@ -0,0 +1,263 @@
+import { createHash, randomUUID } from "node:crypto";
+
+import { authorizeUsage, releaseUsage } from "@/lib/consultation-billing";
+import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver";
+import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing";
+import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service";
+import { loadLanguageModelCatalog } from "@/lib/model-catalog";
+import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes";
+import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement";
+import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
+import { reportView } from "@/lib/personal-report-route-core";
+import {
+ createSupabasePersonalReportService,
+ type PersonalReportRecord,
+} from "@/lib/personal-report-service";
+import { isProductEnabled } from "@/lib/product-access";
+import { consumeUserRateLimitSet, userRateLimitMessages } from "@/lib/request-rate-limit";
+import { ConsultationSubjectError, isChartSubjectId, loadSubjectBirth } from "@/lib/subject-birth";
+
+const SKILL_SNAPSHOT_SHA256 = "5b12d7a51dfecfdfbc29c33263204f5069d79efd71a056c75fddb8151b78b329";
+
+export type SynastryCreateResponse = {
+ status: number;
+ body: Record;
+ headers?: Record;
+};
+
+type Row = Record;
+
+type QueryResult = { data: Row[] | null; error: { message?: string } | null };
+
+type Query = PromiseLike & {
+ eq(column: string, value: unknown): Query;
+ in(column: string, values: readonly string[]): Query;
+ gte(column: string, value: string): Query;
+ limit(count: number): Query;
+ maybeSingle(): Promise<{ data: Row | null; error: { message?: string } | null }>;
+};
+
+function subjectId(value: unknown): string | null {
+ if (value === "self") return "self";
+ return typeof value === "string" && isChartSubjectId(value) ? value : null;
+}
+
+function pairOf(leftId: string, rightId: string, leftFingerprint: string, rightFingerprint: string) {
+ if (leftId <= rightId) {
+ return { low: leftId, high: rightId, fingerprintLow: leftFingerprint, fingerprintHigh: rightFingerprint };
+ }
+ return { low: rightId, high: leftId, fingerprintLow: rightFingerprint, fingerprintHigh: leftFingerprint };
+}
+
+function profileId(subject: string): string | null {
+ return subject === "self" ? null : subject;
+}
+
+function selectReports(admin: unknown, columns: string): Query {
+ return (admin as { from(table: string): { select(columns: string): Query } }).from("personal_reports").select(columns);
+}
+
+export async function createSynastryFullReport(input: Readonly<{
+ userId: string | null;
+ body: unknown;
+ supabase: unknown;
+ admin: Parameters[0];
+}>): Promise {
+ if (!input.userId) return { status: 401, body: { error: "请先登录" } };
+ if (!await isProductEnabled("compatibility") || !await isProductEnabled("report_center")) {
+ return { status: 503, body: { error: "合盘服务暂未开放", code: "compatibility_product_disabled" } };
+ }
+ const body = input.body && typeof input.body === "object" ? input.body as Record : {};
+ const subjectA = subjectId(body.subjectA ?? "self");
+ const subjectB = subjectId(body.subjectB ?? body.with);
+ if (!subjectA || !subjectB) return { status: 400, body: { error: "请选择两个人", code: "subject_required" } };
+ if (subjectA === subjectB) return { status: 400, body: { error: "请选择两个不同的人", code: "same_subject" } };
+
+ let rowA: Record;
+ let rowB: Record;
+ try {
+ const [personA, personB] = await Promise.all([
+ loadSubjectBirth(input.supabase, input.userId, subjectA),
+ loadSubjectBirth(input.supabase, input.userId, subjectB),
+ ]);
+ if (!personA.chartable || !personB.chartable) {
+ return { status: 422, body: { error: "请先完善出生资料", code: REPORT_STABLE_CODES.profileIncomplete } };
+ }
+ rowA = personA.row;
+ rowB = personB.row;
+ } catch (error) {
+ if (error instanceof ConsultationSubjectError) {
+ const failure = consultationSubjectFailureResponse(error);
+ return { status: failure.status, body: failure.body };
+ }
+ throw error;
+ }
+
+ const pair = pairOf(subjectA, subjectB, marriageMatchFingerprint(rowA), marriageMatchFingerprint(rowB));
+ const requestFingerprint = createHash("sha256").update(JSON.stringify({
+ ...pair,
+ rules: MARRIAGE_MATCH_RULES_VERSION,
+ })).digest("hex");
+ const existing = await selectReports(input.admin, "id,request_id,status,created_at,completed_at")
+ .eq("user_id", input.userId)
+ .eq("report_type", "synastry_full")
+ .eq("subject_low", pair.low)
+ .eq("subject_high", pair.high)
+ .eq("fingerprint_low", pair.fingerprintLow)
+ .eq("fingerprint_high", pair.fingerprintHigh)
+ .eq("synastry_rules_version", MARRIAGE_MATCH_RULES_VERSION)
+ .in("status", ["ready", "generating"])
+ .limit(1)
+ .maybeSingle();
+ if (existing.error) throw new Error(existing.error.message ?? "synastry lookup failed");
+ if (existing.data?.status === "ready" || existing.data?.status === "generating") {
+ return {
+ status: existing.data.status === "ready" ? 200 : 202,
+ body: { report: reportView(stubRecord(input.userId, existing.data)), reused: true },
+ };
+ }
+
+ const inFlight = await selectReports(input.admin, "id")
+ .eq("user_id", input.userId)
+ .eq("status", "generating")
+ .limit(1)
+ .maybeSingle();
+ if (inFlight.error) throw new Error(inFlight.error.message ?? "synastry inflight lookup failed");
+ if (inFlight.data) {
+ return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
+ }
+ const todayStart = new Date();
+ todayStart.setHours(0, 0, 0, 0);
+ const today = await selectReports(input.admin, "created_at").eq("user_id", input.userId).gte("created_at", todayStart.toISOString());
+ if (today.error) throw new Error(today.error.message ?? "synastry daily lookup failed");
+ if ((today.data ?? []).length >= readPersonalReportDailyLimit(process.env)) {
+ return { status: 429, body: { error: "今日报告生成次数已达上限", code: REPORT_STABLE_CODES.rateLimited } };
+ }
+
+ const catalog = await loadLanguageModelCatalog();
+ const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
+ if (!model) return { status: 502, body: { error: "报告模型暂不可用", code: REPORT_STABLE_CODES.modelUnavailable } };
+ const limited = consumeUserRateLimitSet(["reportGenerateHour"], input.userId);
+ if (!limited.ok) {
+ return {
+ status: 429,
+ headers: { "Retry-After": String(limited.retryAfterSeconds) },
+ body: { error: userRateLimitMessages.reportGenerate, code: "rate_limited", retryAfterSeconds: limited.retryAfterSeconds },
+ };
+ }
+
+ const requestId = randomUUID();
+ let reserved = false;
+ try {
+ const pricing = await resolveFeaturePricing(input.admin as never, "synastry.full", model.id);
+ const authorization = await authorizeUsage(input.admin as never, {
+ userId: input.userId,
+ requestId,
+ featureKey: "synastry.full",
+ requestedModelId: model.id,
+ creditCost: pricing.credit_cost,
+ });
+ if (!authorization.success) {
+ const insufficient = authorization.reason === REPORT_STABLE_CODES.insufficientCredits;
+ return {
+ status: insufficient ? 402 : 503,
+ body: {
+ error: insufficient ? "报告点数不足" : "暂时无法扣除报告点数",
+ code: insufficient ? REPORT_STABLE_CODES.insufficientCredits : REPORT_STABLE_CODES.fairUseLimited,
+ },
+ };
+ }
+ reserved = true;
+ } catch (error) {
+ if (error instanceof FeaturePricingError) {
+ return {
+ status: 503,
+ body: {
+ error: "计费配置不可用",
+ message: "当前服务的计费配置尚未完成,请联系支持人员,本次不会扣点。",
+ code: "pricing_configuration_unavailable",
+ },
+ };
+ }
+ return { status: 503, body: { error: "暂时无法确认报告点数", code: REPORT_STABLE_CODES.billingUnavailable } };
+ }
+
+ const release = async (reason: string) => {
+ if (!reserved) return;
+ reserved = false;
+ try { await releaseUsage(input.admin as never, input.userId as string, requestId, reason); } catch { /* the RPC is idempotent */ }
+ };
+
+ const persistence = createSupabasePersonalReportService(input.admin);
+ const includesSelf = subjectA === "self" || subjectB === "self";
+ const chartProfileId = includesSelf ? null : profileId(pair.low);
+ const partnerChartProfileId = includesSelf ? (profileId(pair.low) ?? profileId(pair.high)) : profileId(pair.high);
+ let created;
+ try {
+ created = await persistence.createGenerating({
+ userId: input.userId,
+ requestId,
+ requestFingerprint,
+ reportType: "synastry_full",
+ presentationMode: "default",
+ depth: "standard",
+ chartProfileId,
+ partnerChartProfileId,
+ subjectLow: pair.low,
+ subjectHigh: pair.high,
+ fingerprintLow: pair.fingerprintLow,
+ fingerprintHigh: pair.fingerprintHigh,
+ synastryRulesVersion: MARRIAGE_MATCH_RULES_VERSION,
+ skillName: "jyotish-full-synastry-reading",
+ skillVersion: "1.0.0",
+ skillSnapshotSha256: SKILL_SNAPSHOT_SHA256,
+ });
+ } catch (error) {
+ await release(REPORT_STABLE_CODES.generationFailed);
+ throw error;
+ }
+ if (created.kind !== "created") {
+ await release(REPORT_STABLE_CODES.generationInProgress);
+ return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } };
+ }
+ try {
+ const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({
+ userId: input.userId,
+ requestId,
+ requestFingerprint,
+ });
+ return { status: 202, body: { report: reportView(created.record), jobId: enqueued.job.id } };
+ } catch {
+ await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed);
+ await release(REPORT_STABLE_CODES.generationFailed);
+ return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } };
+ }
+}
+
+function stubRecord(userId: string, row: Row): PersonalReportRecord {
+ return {
+ id: String(row.id),
+ userId,
+ sessionId: null,
+ chartProfileId: null,
+ requestId: String(row.request_id ?? ""),
+ requestFingerprint: "0".repeat(64),
+ reportType: "synastry_full",
+ status: row.status === "ready" ? "ready" : "generating",
+ schemaVersion: "synastry_report.v1",
+ presentationMode: "default",
+ depth: "standard",
+ requestedThemes: [],
+ reportDocument: null,
+ calculationHash: null,
+ evidenceHash: null,
+ skillName: "jyotish-full-synastry-reading",
+ skillVersion: "1.0.0",
+ skillSourceCommit: null,
+ skillSnapshotSha256: SKILL_SNAPSHOT_SHA256,
+ failureCode: null,
+ createdAt: typeof row.created_at === "string" ? row.created_at : new Date(0).toISOString(),
+ updatedAt: typeof row.created_at === "string" ? row.created_at : new Date(0).toISOString(),
+ completedAt: typeof row.completed_at === "string" ? row.completed_at : null,
+ };
+}
diff --git a/frontend/src/lib/synastry-full-generate.ts b/frontend/src/lib/synastry-full-generate.ts
new file mode 100644
index 00000000..e85e8834
--- /dev/null
+++ b/frontend/src/lib/synastry-full-generate.ts
@@ -0,0 +1,221 @@
+/**
+ * One section at a time, at most three at once. A failed gate rewrites only
+ * the failed sections, once. The timeline basis lines stay program-owned.
+ */
+
+import { buildTechnicalBasis, type TechnicalBlock } from "./synastry-full-technical.ts";
+import {
+ SECTION_TITLES,
+ SYNASTRY_PROSE_IDS,
+ SYNASTRY_SECTION_IDS,
+ sectionEvidence,
+ systemPrompt,
+ userPrompt,
+ type SynastrySectionId,
+} from "./synastry-full-sections.ts";
+import { buildTimelineSegments, type TimelineSegment, type TimingPeriod } from "./synastry-full-timeline.ts";
+
+export type SynastryReason = Readonly<{
+ plain: string;
+ basis: string;
+ evidence: string;
+ label: string;
+}>;
+
+export type SynastryDelivery = Readonly<{
+ sections: readonly Record[];
+}>;
+
+export type SynastrySnapshot = Readonly<{
+ names: Readonly<{ a: string; b: string }>;
+ sections: ReadonlyArray>;
+ timeline: ReadonlyArray>;
+ practices: ReadonlyArray>;
+ technical: readonly TechnicalBlock[];
+ gate: Readonly<{ ok: true; language: "zh" | "en" }>;
+ english?: Readonly<{
+ sections: SynastrySnapshot["sections"];
+ timeline: SynastrySnapshot["timeline"];
+ practices: SynastrySnapshot["practices"];
+ gate: Readonly<{ ok: true; language: "en" }>;
+ }>;
+}>;
+
+export type SectionWriter = (input: Readonly<{
+ system: string;
+ user: string;
+ sectionId: SynastrySectionId;
+ signal: AbortSignal;
+}>) => Promise>;
+
+export type ReaderGate = (input: Readonly<{
+ delivery: SynastryDelivery;
+ sectionInputs: Readonly>;
+ language: "zh" | "en";
+}>) => Promise[] }>>;
+
+type ProseDraft = Readonly<{ text: string; reasons: readonly SynastryReason[] }>;
+type TimelineDraft = ReadonlyArray>;
+type PracticeDraft = ReadonlyArray>;
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+}
+
+function proseDraft(value: unknown): ProseDraft | null {
+ const row = asRecord(value);
+ if (!row || typeof row.text !== "string" || !Array.isArray(row.reasons)) return null;
+ const reasons = row.reasons.flatMap((item) => {
+ const reason = asRecord(item);
+ if (!reason || typeof reason.plain !== "string" || typeof reason.basis !== "string" || typeof reason.evidence !== "string") return [];
+ return [{ plain: reason.plain, basis: reason.basis, evidence: reason.evidence, label: typeof reason.label === "string" ? reason.label : "" }];
+ });
+ return { text: row.text, reasons };
+}
+
+function timelineDraft(value: unknown): TimelineDraft | null {
+ const row = asRecord(value);
+ const segments = row && Array.isArray(row.segments) ? row.segments : null;
+ if (!segments) return null;
+ const parsed = segments.flatMap((item) => {
+ const segment = asRecord(item);
+ if (!segment || typeof segment.id !== "string" || typeof segment.phase !== "string" || typeof segment.text !== "string") return [];
+ return [{ id: segment.id, phase: segment.phase, text: segment.text }];
+ });
+ return parsed.length === segments.length ? parsed : null;
+}
+
+function practiceDraft(value: unknown): PracticeDraft | null {
+ const row = asRecord(value);
+ const items = row && Array.isArray(row.items) ? row.items : null;
+ if (!items) return null;
+ const parsed = items.flatMap((item) => {
+ const practice = asRecord(item);
+ if (!practice || typeof practice.text !== "string" || typeof practice.evidence !== "string") return [];
+ return [{ text: practice.text, evidence: practice.evidence }];
+ });
+ return parsed.length > 0 ? parsed : null;
+}
+
+async function pool(items: readonly T[], limit: number, run: (item: T) => Promise): Promise {
+ let cursor = 0;
+ async function worker() {
+ while (cursor < items.length) {
+ const index = cursor;
+ cursor += 1;
+ await run(items[index]);
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
+}
+
+function deliveryFrom(parts: ReadonlyMap>): SynastryDelivery {
+ return { sections: SYNASTRY_SECTION_IDS.map((id) => parts.get(id) ?? { id }) };
+}
+
+export async function writeSynastryLanguage(input: Readonly<{
+ language: "zh" | "en";
+ names: Readonly<{ a: string; b: string }>;
+ dossier: unknown;
+ segments: readonly TimelineSegment[];
+ signal: AbortSignal;
+ write: SectionWriter;
+ gate: ReaderGate;
+ onProgress?: (phase: string) => Promise | void;
+ timeoutMs?: number;
+}>): Promise; inputTokens: number; outputTokens: number }>> {
+ const timeoutMs = input.timeoutMs ?? 90_000;
+ const evidence = Object.fromEntries(SYNASTRY_SECTION_IDS.map((id) => [id, sectionEvidence(input.dossier, id, input.segments)])) as Record;
+ const drafts = new Map();
+ let inputTokens = 0;
+ let outputTokens = 0;
+
+ async function writeOne(sectionId: SynastrySectionId, failures?: readonly string[]) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ const onAbort = () => controller.abort();
+ input.signal.addEventListener("abort", onAbort, { once: true });
+ try {
+ const written = await input.write({
+ system: systemPrompt(input.language),
+ user: userPrompt({
+ language: input.language,
+ names: input.names,
+ sectionId,
+ evidence: evidence[sectionId],
+ failures,
+ }),
+ sectionId,
+ signal: controller.signal,
+ });
+ inputTokens += written.inputTokens;
+ outputTokens += written.outputTokens;
+ drafts.set(sectionId, written.body);
+ } finally {
+ clearTimeout(timer);
+ input.signal.removeEventListener("abort", onAbort);
+ }
+ }
+
+ await input.onProgress?.("writing_report");
+ await pool(SYNASTRY_SECTION_IDS, 3, (sectionId) => writeOne(sectionId));
+
+ function materialize(): Map> {
+ const parts = new Map>();
+ for (const sectionId of SYNASTRY_PROSE_IDS) {
+ const draft = proseDraft(drafts.get(sectionId));
+ if (!draft) throw new Error("synastry_section_invalid");
+ parts.set(sectionId, { id: sectionId, title: SECTION_TITLES[sectionId][input.language], ...draft });
+ }
+ const phases = timelineDraft(drafts.get("timeline"));
+ if (!phases) throw new Error("synastry_section_invalid");
+ parts.set("timeline", {
+ id: "timeline",
+ segments: input.segments.map((segment) => {
+ const written = phases.find((item) => item.id === segment.id);
+ return { ...segment, phase: written?.phase ?? "", text: written?.text ?? "" };
+ }),
+ });
+ const practices = practiceDraft(drafts.get("practices"));
+ if (!practices) throw new Error("synastry_section_invalid");
+ parts.set("practices", { id: "practices", items: practices });
+ return parts;
+ }
+
+ await input.onProgress?.("checking_report");
+ let parts = materialize();
+ let gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
+ if (!gated.ok) {
+ const failed = new Set(gated.failures.map((failure) => failure.section).filter((section) => SYNASTRY_SECTION_IDS.includes(section as SynastrySectionId)));
+ const targets = failed.size > 0 ? [...failed] as SynastrySectionId[] : [...SYNASTRY_SECTION_IDS];
+ await pool(targets, 3, (sectionId) => writeOne(sectionId, gated.failures.filter((failure) => failure.section === sectionId).map((failure) => failure.code)));
+ parts = materialize();
+ gated = await input.gate({ delivery: deliveryFrom(parts), sectionInputs: evidence, language: input.language });
+ }
+ if (!gated.ok) throw new Error("synastry_gate_rejected");
+
+ const sections = SYNASTRY_PROSE_IDS.map((id) => parts.get(id) as { id: SynastrySectionId; title: string; text: string; reasons: SynastryReason[] });
+ const timeline = (parts.get("timeline")?.segments ?? []) as SynastrySnapshot["timeline"];
+ const practices = (parts.get("practices")?.items ?? []) as SynastrySnapshot["practices"];
+ const technical = buildTechnicalBasis({
+ dossier: input.dossier,
+ names: input.names,
+ contacts: sections.flatMap((section) => section.reasons.map((reason) => reason.basis)),
+ timing: timeline.map((segment) => segment.basis),
+ });
+ return {
+ snapshotParts: { sections, timeline, practices, technical, gate: { ok: true, language: input.language } },
+ inputTokens,
+ outputTokens,
+ };
+}
+
+export function timelineForPair(input: Readonly<{
+ nameA: string;
+ nameB: string;
+ periodsA: readonly TimingPeriod[];
+ periodsB: readonly TimingPeriod[];
+ from: string;
+}>): TimelineSegment[] {
+ return buildTimelineSegments(input);
+}
diff --git a/frontend/src/lib/synastry-full-run.ts b/frontend/src/lib/synastry-full-run.ts
new file mode 100644
index 00000000..bced4fc8
--- /dev/null
+++ b/frontend/src/lib/synastry-full-run.ts
@@ -0,0 +1,277 @@
+import "server-only";
+
+import { createHash } from "node:crypto";
+
+import { createSynastrySectionWriter } from "@/mastra/synastry-report";
+import { loadLanguageModelCatalog } from "@/lib/model-catalog";
+import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core";
+import type { PersonalReportRecord } from "@/lib/personal-report-service-core";
+import { loadSubjectBirth } from "@/lib/subject-birth";
+import { marriageMatchBirthBody } from "@/lib/marriage-match-service";
+import { timelineForPair, writeSynastryLanguage, type SynastrySnapshot } from "@/lib/synastry-full-generate";
+import type { TimingPeriod } from "@/lib/synastry-full-timeline";
+
+const API_BASE = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
+const SNAPSHOT_SECTION = "synastry-full-snapshot";
+
+type AdminClient = {
+ from(table: string): {
+ select(columns: string): Query;
+ insert(row: Record): Promise<{ error: { message?: string } | null }>;
+ update(values: Record): Query;
+ upsert(row: Record): Promise<{ error: { message?: string } | null }>;
+ };
+};
+
+type QueryResult = { data: unknown; error: { message?: string } | null };
+
+type Query = PromiseLike & {
+ eq(column: string, value: string): Query;
+ in(column: string, values: readonly string[]): Query;
+ limit(value: number): Query;
+ maybeSingle(): Promise;
+};
+
+function sha256(value: unknown): string {
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
+}
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+}
+
+function displayName(row: Record, fallback: string): string {
+ const name = typeof row.name === "string" ? row.name.trim() : "";
+ return (name || fallback).slice(0, 40);
+}
+
+async function postJson(path: string, body: unknown, signal?: AbortSignal): Promise {
+ const response = await fetch(`${API_BASE.replace(/\/$/, "")}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json", accept: "application/json" },
+ body: JSON.stringify(body),
+ cache: "no-store",
+ signal,
+ });
+ if (response.status === 429) {
+ throw new PersonalReportWorkerError("calculation_unavailable", true, "upstream_busy");
+ }
+ if (!response.ok) throw new PersonalReportWorkerError("calculation_unavailable", true);
+ return response.json().catch(() => null);
+}
+
+function periodsOf(value: unknown): TimingPeriod[] {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((item) => {
+ const row = asRecord(item);
+ if (!row || (row.level !== "md" && row.level !== "ad")) return [];
+ if (typeof row.lord !== "string" || typeof row.start !== "string" || typeof row.end !== "string") return [];
+ return [{
+ level: row.level,
+ lord: row.lord,
+ lordCn: typeof row.lordCn === "string" ? row.lordCn : row.lord,
+ start: row.start,
+ end: row.end,
+ }];
+ });
+}
+
+async function readSnapshot(admin: AdminClient, report: PersonalReportRecord): Promise | null> {
+ const result = await admin.from("personal_report_sections")
+ .select("payload")
+ .eq("user_id", report.userId)
+ .eq("request_id", report.requestId)
+ .eq("section_id", SNAPSHOT_SECTION)
+ .maybeSingle();
+ if (result.error) throw new PersonalReportWorkerError("calculation_unavailable", true);
+ return asRecord(asRecord(result.data)?.payload);
+}
+
+async function writeSnapshot(admin: AdminClient, report: PersonalReportRecord, payload: Record) {
+ const saved = await admin.from("personal_report_sections").upsert({
+ user_id: report.userId,
+ request_id: report.requestId,
+ section_id: SNAPSHOT_SECTION,
+ status: "ready",
+ payload,
+ attempt_count: 1,
+ max_attempts: 2,
+ });
+ if (saved.error) throw new PersonalReportWorkerError("calculation_unavailable", true);
+}
+
+export function publicSynastryView(payload: unknown): SynastrySnapshot | null {
+ const row = asRecord(payload);
+ const names = asRecord(row?.names);
+ const zh = asRecord(row?.zh);
+ if (!row || !names || typeof names.a !== "string" || typeof names.b !== "string" || !zh) return null;
+ const view: SynastrySnapshot = {
+ names: { a: names.a, b: names.b },
+ sections: zh.sections as SynastrySnapshot["sections"],
+ timeline: zh.timeline as SynastrySnapshot["timeline"],
+ practices: zh.practices as SynastrySnapshot["practices"],
+ technical: zh.technical as SynastrySnapshot["technical"],
+ gate: { ok: true, language: "zh" },
+ };
+ const english = asRecord(row.english);
+ if (english) {
+ return {
+ ...view,
+ english: {
+ sections: english.sections as SynastrySnapshot["sections"],
+ timeline: english.timeline as SynastrySnapshot["timeline"],
+ practices: english.practices as SynastrySnapshot["practices"],
+ gate: { ok: true, language: "en" },
+ },
+ };
+ }
+ return view;
+}
+
+export async function runSynastryFullReport(input: Readonly<{
+ admin: AdminClient;
+ report: PersonalReportRecord;
+ signal: AbortSignal;
+ onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise | void;
+}>): Promise<{ status: "synastry_persisted"; usage: { inputTokens: number; outputTokens: number; actualModelId: string; modelConfigVersion?: number } }> {
+ if (!input.report.subjectLow || !input.report.subjectHigh) {
+ throw new PersonalReportWorkerError("profile_incomplete", false);
+ }
+ await input.onProgress?.({ phase: "computing_charts", completed: 0, total: 9 });
+ const today = new Date().toISOString().slice(0, 10);
+ const [personA, personB] = await Promise.all([
+ loadSubjectBirth(input.admin, input.report.userId, input.report.subjectLow),
+ loadSubjectBirth(input.admin, input.report.userId, input.report.subjectHigh),
+ ]);
+ const packet = asRecord(await postJson("/api/synastry_full_dossier", {
+ person_a: marriageMatchBirthBody(personA.row, today),
+ person_b: marriageMatchBirthBody(personB.row, today),
+ }, input.signal));
+ const dossier = packet?.dossier;
+ if (!dossier) throw new PersonalReportWorkerError("calculation_unavailable", true);
+ const timing = asRecord(packet?.timing_periods);
+ const names = { a: displayName(personA.row, "甲"), b: displayName(personB.row, "乙") };
+ const segments = timelineForPair({
+ nameA: names.a,
+ nameB: names.b,
+ periodsA: periodsOf(timing?.person_a),
+ periodsB: periodsOf(timing?.person_b),
+ from: today,
+ });
+ if (segments.length < 3) throw new PersonalReportWorkerError("calculation_unavailable", false);
+ const catalog = await loadLanguageModelCatalog();
+ const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
+ if (!model) throw new PersonalReportWorkerError("model_unavailable", true);
+ const writer = createSynastrySectionWriter(model);
+ let written;
+ try {
+ written = await writeSynastryLanguage({
+ language: "zh",
+ names,
+ dossier,
+ segments,
+ signal: input.signal,
+ write: writer,
+ gate: async (gateInput) => {
+ const result = asRecord(await postJson("/api/synastry_full_reader_gate", gateInput, input.signal));
+ const failures = Array.isArray(result?.failures) ? result.failures as { section: string; code: string }[] : [];
+ return { ok: result?.ok === true, failures };
+ },
+ onProgress: (phase) => input.onProgress?.({ phase, completed: 0, total: 9 }),
+ });
+ } catch (error) {
+ if (error instanceof PersonalReportWorkerError) throw error;
+ if (error instanceof Error && error.message === "synastry_gate_rejected") {
+ throw new PersonalReportWorkerError("report_guard_rejected", false);
+ }
+ throw new PersonalReportWorkerError("model_unavailable", true);
+ }
+ const payload = {
+ kind: "synastry_full_snapshot",
+ version: 1,
+ names,
+ dossier,
+ zh: written.snapshotParts,
+ };
+ await input.onProgress?.({ phase: "persisting_report", completed: 9, total: 9 });
+ await writeSnapshot(input.admin, input.report, payload);
+ const completedAt = new Date().toISOString();
+ const saved = await input.admin.from("personal_reports").update({
+ status: "ready",
+ schema_version: "synastry_report.v1",
+ report_document: { schemaVersion: "synastry_report.v1", reportId: input.report.id, names, sectionId: SNAPSHOT_SECTION },
+ calculation_hash: sha256(dossier),
+ evidence_hash: sha256(written.snapshotParts),
+ card_summary: `${names.a} × ${names.b} 婚恋合盘`.slice(0, 500),
+ completed_at: completedAt,
+ updated_at: completedAt,
+ synastry_report_id: typeof packet?.synastry_report_id === "string" ? packet.synastry_report_id : input.report.synastryReportId ?? null,
+ }).eq("id", input.report.id).eq("user_id", input.report.userId).eq("status", "generating");
+ if (saved.error) throw new PersonalReportWorkerError("calculation_unavailable", true);
+ return {
+ status: "synastry_persisted",
+ usage: {
+ inputTokens: written.inputTokens,
+ outputTokens: written.outputTokens,
+ actualModelId: model.id,
+ modelConfigVersion: model.configVersion,
+ },
+ };
+}
+
+export async function runIdleSynastryEnglish(admin: AdminClient): Promise {
+ const queued = await admin.from("synastry_english_jobs").select("report_id,user_id,attempt_count,max_attempts").eq("status", "queued").limit(1).maybeSingle();
+ const job = asRecord(queued.data);
+ const reportId = typeof job?.report_id === "string" ? job.report_id : "";
+ const userId = typeof job?.user_id === "string" ? job.user_id : "";
+ if (!reportId || !userId) return null;
+ const claimed = await admin.from("synastry_english_jobs").update({
+ status: "running",
+ attempt_count: Number(job?.attempt_count ?? 0) + 1,
+ updated_at: new Date().toISOString(),
+ }).eq("report_id", reportId).eq("status", "queued");
+ if (claimed.error) return null;
+ const reportRow = await admin.from("personal_reports").select("id,user_id,request_id,report_type,status").eq("id", reportId).eq("user_id", userId).maybeSingle();
+ const report = asRecord(reportRow.data);
+ if (!report || report.report_type !== "synastry_full") return null;
+ const stored = await readSnapshot(admin, {
+ userId,
+ requestId: String(report.request_id),
+ } as PersonalReportRecord);
+ const dossier = stored?.dossier;
+ const names = asRecord(stored?.names);
+ const zh = asRecord(stored?.zh);
+ const timeline = Array.isArray(zh?.timeline) ? zh.timeline : [];
+ if (!dossier || !names || typeof names.a !== "string" || typeof names.b !== "string") return null;
+ try {
+ const catalog = await loadLanguageModelCatalog();
+ const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId);
+ if (!model) throw new Error("model_unavailable");
+ const written = await writeSynastryLanguage({
+ language: "en",
+ names: { a: names.a, b: names.b },
+ dossier,
+ segments: timeline as SynastrySnapshot["timeline"],
+ signal: new AbortController().signal,
+ write: createSynastrySectionWriter(model),
+ gate: async (gateInput) => {
+ const result = asRecord(await postJson("/api/synastry_full_reader_gate", gateInput));
+ return { ok: result?.ok === true, failures: Array.isArray(result?.failures) ? result.failures as { section: string; code: string }[] : [] };
+ },
+ });
+ await writeSnapshot(admin, { userId, requestId: String(report.request_id) } as PersonalReportRecord, {
+ ...stored,
+ english: written.snapshotParts,
+ });
+ await admin.from("synastry_english_jobs").update({ status: "ready", updated_at: new Date().toISOString() }).eq("report_id", reportId);
+ } catch {
+ await admin.from("synastry_english_jobs").update({
+ status: "failed",
+ last_error_code: "report_guard_rejected",
+ updated_at: new Date().toISOString(),
+ }).eq("report_id", reportId);
+ }
+ return reportId;
+}
+
+export { SNAPSHOT_SECTION };
diff --git a/frontend/src/lib/synastry-full-sections.ts b/frontend/src/lib/synastry-full-sections.ts
new file mode 100644
index 00000000..572e8057
--- /dev/null
+++ b/frontend/src/lib/synastry-full-sections.ts
@@ -0,0 +1,130 @@
+/** Nine sections, the evidence each one may see, and the prompts around that evidence. */
+
+export const SYNASTRY_SECTION_IDS = [
+ "climate",
+ "emotion",
+ "communication",
+ "safety",
+ "conflict",
+ "daily",
+ "direction",
+ "timeline",
+ "practices",
+] as const;
+
+export type SynastrySectionId = (typeof SYNASTRY_SECTION_IDS)[number];
+
+export const SYNASTRY_PROSE_IDS = SYNASTRY_SECTION_IDS.slice(0, 7);
+
+export const SECTION_TITLES: Readonly>> = {
+ climate: { zh: "关系基调", en: "Relationship Climate" },
+ emotion: { zh: "情感节奏", en: "Emotional Rhythm" },
+ communication: { zh: "沟通与理解", en: "Communication and Understanding" },
+ safety: { zh: "安全感与亲密", en: "Safety and Intimacy" },
+ conflict: { zh: "冲突与修复", en: "Conflict and Repair" },
+ daily: { zh: "生活节律与现实协作", en: "Daily Life and Practical Partnership" },
+ direction: { zh: "长期方向", en: "Long-term Direction" },
+ timeline: { zh: "关系时间线", en: "Relationship Timeline" },
+ practices: { zh: "共同练习", en: "Practices to Share" },
+};
+
+const SECTION_FIELDS: Readonly> = {
+ climate: ["reader_section_brief", "relationship_lenses"],
+ emotion: ["emotional_safety"],
+ communication: ["communication_conflict_and_repair"],
+ safety: ["attraction_and_closeness", "mutual_affection_activation"],
+ conflict: ["communication_conflict_and_repair", "can_it_stay_steady"],
+ daily: ["shared_life_gains_and_pressures"],
+ direction: ["relationship_progression", "can_it_stay_steady"],
+ timeline: ["parallel_timing_context", "event_resonance_gate"],
+ practices: ["reader_section_brief"],
+};
+
+const BACKGROUND_SECTIONS = new Set(["emotion", "safety", "conflict", "daily"]);
+
+const PRIVATE_KEYS = /birth|date|time|lat|lon|city|place|timezone|year|month|day|hour|minute/i;
+
+export const TALK_TOGETHER = [
+ "靠近的时候,怎样的回应会让你觉得被放在心上?",
+ "误会出现时,你们更需要先说开,还是先各自安静一会儿?",
+ "吵完之后,怎样递出台阶才不会变成认输?",
+ "过日子的安排里,哪一件需要现在就说清楚?",
+] as const;
+
+export const SYNASTRY_FOOTNOTE = "合盘描述的是互动的质地,走不走、怎么走,仍然是你们两个人的选择。";
+
+function record(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+}
+
+function scrub(value: unknown): unknown {
+ if (Array.isArray(value)) return value.slice(0, 24).map(scrub);
+ const row = record(value);
+ if (!row) return value;
+ const next: Record = {};
+ for (const [key, item] of Object.entries(row)) {
+ if (PRIVATE_KEYS.test(key)) continue;
+ next[key] = scrub(item);
+ }
+ return next;
+}
+
+export function sectionEvidence(dossier: unknown, sectionId: SynastrySectionId, timeline?: unknown): unknown {
+ const packet = record(dossier) ?? {};
+ const picked: Record = {};
+ for (const field of SECTION_FIELDS[sectionId]) {
+ if (field in packet) picked[field] = scrub(packet[field]);
+ }
+ if (BACKGROUND_SECTIONS.has(sectionId) && packet.marriage_match) {
+ const match = record(packet.marriage_match);
+ picked.background = scrub({
+ status: match?.status,
+ profile: record(match?.rule_profile)?.id,
+ note: "只把传统配对翻译成关系语言,不要写分数",
+ });
+ }
+ if (sectionId === "timeline" && timeline) picked.segments = timeline;
+ return picked;
+}
+
+const ZH_SYSTEM = `你在写一份婚恋合盘的关系画像。把盘面翻译成两个人相处时的质地,不要做判决。
+说「你们之间」,不说「你 / 他是什么人」。说倾向,不说必然。说需求,不说缺点。给条件,不给结论。保留双方主体性。
+结论、所以、时间线描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。
+盘面行可以写术语,但必须逐字来自给你的证据。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。
+第一句不要写成「这段关系的核心不是……」。`;
+
+const EN_SYSTEM = `You write a relationship portrait of two people. Translate the evidence into how the relationship feels. Do not judge the people.
+Speak about the pair, not about who someone is. Speak in tendencies, needs, and conditions. Keep both people as agents.
+The conclusion, the "so" line, the timeline description, and the shared practices must not name planets, houses, signs, divisional charts, dashas, or scores.
+The chart line may use those terms only when the supplied evidence contains them. Do not include birth dates, times, or places. Do not predict a wedding or a breakup in a named year.
+Do not open with "The core of this relationship is not".`;
+
+export function systemPrompt(language: "zh" | "en"): string {
+ return language === "zh" ? ZH_SYSTEM : EN_SYSTEM;
+}
+
+export function userPrompt(input: Readonly<{
+ language: "zh" | "en";
+ names: Readonly<{ a: string; b: string }>;
+ sectionId: SynastrySectionId;
+ evidence: unknown;
+ failures?: readonly string[];
+}>): string {
+ const title = SECTION_TITLES[input.sectionId][input.language];
+ const evidence = JSON.stringify(input.evidence);
+ if (input.language === "en") {
+ const repair = input.failures && input.failures.length > 0
+ ? `\nThe previous draft failed this section only: ${input.failures.join("; ")}`
+ : "";
+ return [`Names: ${input.names.a} and ${input.names.b}`, `Section: ${title}`, `Evidence: ${evidence}`, repair].filter(Boolean).join("\n");
+ }
+ const repair = input.failures && input.failures.length > 0
+ ? `\n上次没有通过,只改这一节:${input.failures.join(";")}`
+ : "";
+ return [
+ `两人称呼:${input.names.a}、${input.names.b}`,
+ `这一节:${title}`,
+ `证据:${evidence}`,
+ repair,
+ ].filter(Boolean).join("\n");
+}
diff --git a/frontend/src/lib/synastry-full-technical.ts b/frontend/src/lib/synastry-full-technical.ts
new file mode 100644
index 00000000..851cdd0d
--- /dev/null
+++ b/frontend/src/lib/synastry-full-technical.ts
@@ -0,0 +1,92 @@
+/**
+ * Technical appendix. Built only from the evidence packet, never from the model.
+ */
+
+export type TechnicalBlock = Readonly<{
+ id: "parameters" | "structures" | "contacts" | "timing" | "traditional" | "execution";
+ title: string;
+ lines: readonly string[];
+}>;
+
+const STATUS_LABELS: Readonly> = {
+ executed: "已执行",
+ ready_for_comparison: "已执行",
+ present: "已执行",
+ used: "已执行",
+ available: "已执行",
+ passed: "已执行",
+ parameter_sensitive: "参数敏感",
+ partial: "参数敏感",
+ weak: "参数敏感",
+ observation_only: "参数敏感",
+ not_applicable: "不适用",
+ not_used: "不适用",
+ not_used_for_date_intersection: "不适用",
+};
+
+function record(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+}
+
+function text(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function statusLabel(value: unknown): string {
+ const key = text(value).toLowerCase();
+ return STATUS_LABELS[key] ?? "无法计算";
+}
+
+function linesOf(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value.flatMap((row) => {
+ const item = record(row);
+ if (!item) return [];
+ const label = text(item.technique) || text(item.task) || text(item.label) || text(item.lane);
+ if (!label) return [];
+ return [`${label}:${statusLabel(item.status)}`];
+ });
+}
+
+export function buildTechnicalBasis(input: Readonly<{
+ dossier: unknown;
+ names: Readonly<{ a: string; b: string }>;
+ contacts: readonly string[];
+ timing: readonly string[];
+}>): TechnicalBlock[] {
+ const dossier = record(input.dossier) ?? {};
+ const profile = record(dossier.calculation_profile) ?? {};
+ const receipt = record(dossier.execution_receipt) ?? {};
+ const snapshots = record(dossier.chart_snapshots) ?? {};
+ const match = record(dossier.marriage_match) ?? {};
+ const parameters = [
+ `岁差:${text(profile.ayanamsa) || "未标明"}`,
+ `交点:${text(profile.node_mode) || text(profile.nodeMode) || "未标明"}`,
+ `宫制:${text(profile.house_system) || "未标明"}`,
+ "出生时间精度:以各自档案里已经保存的时间为准,这里不显示日期和地点",
+ ];
+ const structure = (["a", "b"] as const).map((side) => {
+ const snapshot = record(snapshots[side === "a" ? "person_a" : "person_b"]) ?? {};
+ const planets = record(snapshot.planets) ?? {};
+ const name = input.names[side];
+ return `${name}:上升 ${text(snapshot.ascendant) || "未标明"};金星 ${text(record(planets.Venus)?.sign) || text(planets.Venus) || "未标明"};月亮 ${text(record(planets.Moon)?.sign) || text(planets.Moon) || "未标明"}`;
+ });
+ const traditional = [
+ "只作一层参考,不决定结论",
+ text(record(match.rule_profile)?.id) || "传统配对",
+ ...linesOf(record(match.ashtakoota_36)?.items ?? match.items),
+ ];
+ const execution = [
+ ...linesOf(receipt.individual_technique_audit),
+ ...linesOf(receipt.pair_execution_tasks),
+ "外部资料与真实案例核对:未进行,本报告是计算层归纳",
+ ];
+ return [
+ { id: "parameters", title: "计算参数", lines: parameters },
+ { id: "structures", title: "各自的婚恋结构", lines: structure },
+ { id: "contacts", title: "两盘交感", lines: input.contacts.length > 0 ? input.contacts : ["这一份报告的分析逻辑没有引用额外盘面条目"] },
+ { id: "timing", title: "大运对照", lines: input.timing.length > 0 ? input.timing : ["时间线没有可用的大运边界"] },
+ { id: "traditional", title: "传统配对参考", lines: traditional },
+ { id: "execution", title: "技法执行情况", lines: execution },
+ ];
+}
diff --git a/frontend/src/lib/synastry-full-timeline.ts b/frontend/src/lib/synastry-full-timeline.ts
new file mode 100644
index 00000000..79b35e8e
--- /dev/null
+++ b/frontend/src/lib/synastry-full-timeline.ts
@@ -0,0 +1,163 @@
+/**
+ * Deterministic relationship-timeline windows.
+ * The model may name a phase. It may not rewrite these basis lines.
+ */
+
+export type TimingPeriod = Readonly<{
+ level: "md" | "ad";
+ lord: string;
+ lordCn: string;
+ start: string;
+ end: string;
+}>;
+
+export type TimelineSegment = Readonly<{
+ id: string;
+ start: string;
+ end: string;
+ basis: string;
+}>;
+
+const MONTH_MS = 183 * 24 * 60 * 60 * 1000;
+
+function parseDay(value: string): number {
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
+ if (!match) return Number.NaN;
+ return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
+}
+
+function formatMonth(value: number): string {
+ const date = new Date(value);
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
+ return `${date.getUTCFullYear()}-${month}`;
+}
+
+function covers(period: TimingPeriod, at: number): boolean {
+ const start = parseDay(period.start);
+ const end = parseDay(period.end);
+ return Number.isFinite(start) && Number.isFinite(end) && start <= at && at < end;
+}
+
+function basisLine(name: string, periods: readonly TimingPeriod[], at: number): string {
+ const mahadasha = periods.find((period) => period.level === "md" && covers(period, at));
+ const antardasha = periods.find((period) => period.level === "ad" && covers(period, at));
+ const parts = [mahadasha, antardasha].flatMap((period) => period
+ ? [`${period.lordCn}${period.level === "md" ? "大运" : "小运"} ${formatMonth(parseDay(period.start))} 至 ${formatMonth(parseDay(period.end))}`]
+ : []);
+ return parts.length > 0 ? `${name}:${parts.join(";")}` : `${name}:这段没有可用的大运边界`;
+}
+
+type MutableSegment = { start: number; end: number; basis: string };
+
+function mergeShort(segments: MutableSegment[]): MutableSegment[] {
+ const merged: MutableSegment[] = [];
+ for (const segment of segments) {
+ const short = segment.end - segment.start < MONTH_MS;
+ if (!short) {
+ merged.push(segment);
+ continue;
+ }
+ if (merged.length > 0) {
+ merged[merged.length - 1] = { ...merged[merged.length - 1], end: segment.end };
+ continue;
+ }
+ merged.push(segment);
+ }
+ if (merged.length > 1 && merged[0].end - merged[0].start < MONTH_MS) {
+ merged[1] = { ...merged[1], start: merged[0].start };
+ merged.shift();
+ }
+ return merged;
+}
+
+function mergeUntilFive(segments: MutableSegment[]): MutableSegment[] {
+ const next = segments.slice();
+ while (next.length > 5) {
+ let index = 0;
+ let width = next[0].end - next[0].start;
+ for (let cursor = 1; cursor < next.length; cursor += 1) {
+ const span = next[cursor].end - next[cursor].start;
+ if (span < width) {
+ index = cursor;
+ width = span;
+ }
+ }
+ const target = index === 0 ? 1 : index - 1;
+ const left = Math.min(index, target);
+ const right = Math.max(index, target);
+ next.splice(left, 2, {
+ start: next[left].start,
+ end: next[right].end,
+ basis: next[left].basis,
+ });
+ }
+ return next;
+}
+
+function splitUntilThree(segments: MutableSegment[], periodsA: readonly TimingPeriod[], periodsB: readonly TimingPeriod[], nameA: string, nameB: string): MutableSegment[] {
+ const next = segments.slice();
+ while (next.length > 0 && next.length < 3) {
+ let index = 0;
+ let width = next[0].end - next[0].start;
+ for (let cursor = 1; cursor < next.length; cursor += 1) {
+ const span = next[cursor].end - next[cursor].start;
+ if (span > width) {
+ index = cursor;
+ width = span;
+ }
+ }
+ const segment = next[index];
+ if (segment.end - segment.start < 2 * 24 * 60 * 60 * 1000) break;
+ const mid = segment.start + Math.floor((segment.end - segment.start) / 2);
+ next.splice(index, 1, {
+ start: segment.start,
+ end: mid,
+ basis: `${basisLine(nameA, periodsA, segment.start)}。${basisLine(nameB, periodsB, segment.start)}`,
+ }, {
+ start: mid,
+ end: segment.end,
+ basis: `${basisLine(nameA, periodsA, mid)}。${basisLine(nameB, periodsB, mid)}`,
+ });
+ }
+ return next;
+}
+
+export function buildTimelineSegments(input: Readonly<{
+ nameA: string;
+ nameB: string;
+ periodsA: readonly TimingPeriod[];
+ periodsB: readonly TimingPeriod[];
+ from: string;
+}>): TimelineSegment[] {
+ const start = parseDay(input.from);
+ if (!Number.isFinite(start)) return [];
+ const end = Date.UTC(new Date(start).getUTCFullYear() + 5, new Date(start).getUTCMonth(), new Date(start).getUTCDate());
+ const bounds = new Set([start, end]);
+ for (const period of [...input.periodsA, ...input.periodsB]) {
+ const periodStart = parseDay(period.start);
+ const periodEnd = parseDay(period.end);
+ if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) continue;
+ if (periodEnd <= start || periodStart >= end) continue;
+ bounds.add(Math.max(periodStart, start));
+ bounds.add(Math.min(periodEnd, end));
+ }
+ const points = [...bounds].sort((left, right) => left - right);
+ const raw: MutableSegment[] = [];
+ for (let index = 0; index < points.length - 1; index += 1) {
+ const segStart = points[index];
+ const segEnd = points[index + 1];
+ if (segEnd <= segStart) continue;
+ raw.push({
+ start: segStart,
+ end: segEnd,
+ basis: `${basisLine(input.nameA, input.periodsA, segStart)}。${basisLine(input.nameB, input.periodsB, segStart)}`,
+ });
+ }
+ const sized = splitUntilThree(mergeUntilFive(mergeShort(raw)), input.periodsA, input.periodsB, input.nameA, input.nameB);
+ return sized.slice(0, 5).map((segment, index) => ({
+ id: `s${index + 1}`,
+ start: formatMonth(segment.start),
+ end: formatMonth(segment.end),
+ basis: segment.basis,
+ }));
+}
diff --git a/frontend/src/mastra/synastry-report.ts b/frontend/src/mastra/synastry-report.ts
new file mode 100644
index 00000000..594625be
--- /dev/null
+++ b/frontend/src/mastra/synastry-report.ts
@@ -0,0 +1,70 @@
+import { Agent } from "@mastra/core/agent";
+import { z } from "zod";
+
+import type { ResolvedLanguageModel } from "./model.ts";
+import type { SynastrySectionId } from "@/lib/synastry-full-sections.ts";
+
+const reasonSchema = z.object({
+ plain: z.string().min(1),
+ basis: z.string().min(1),
+ evidence: z.string().min(1),
+ label: z.string().min(1),
+}).strict();
+
+const proseSchema = z.object({
+ text: z.string().min(1),
+ reasons: z.array(reasonSchema).min(2).max(3),
+}).strict();
+
+const timelineSchema = z.object({
+ segments: z.array(z.object({
+ id: z.string().min(1),
+ phase: z.string().min(1),
+ text: z.string().min(1),
+ }).strict()).min(1),
+}).strict();
+
+const practiceSchema = z.object({
+ items: z.array(z.object({
+ text: z.string().min(1),
+ evidence: z.string().min(1),
+ }).strict()).min(1),
+}).strict();
+
+function schemaFor(sectionId: SynastrySectionId) {
+ if (sectionId === "timeline") return timelineSchema;
+ if (sectionId === "practices") return practiceSchema;
+ return proseSchema;
+}
+
+export function createSynastrySectionWriter(model: ResolvedLanguageModel) {
+ const agent = new Agent({
+ id: `synastry-report-${model.id}`,
+ name: "Synastry Report Writer",
+ model: model.model,
+ instructions: "Follow the user message. Return only the requested JSON.",
+ });
+ return async function writeSection(input: Readonly<{
+ system: string;
+ user: string;
+ sectionId: SynastrySectionId;
+ signal: AbortSignal;
+ }>) {
+ const result = await agent.generate(
+ [
+ { role: "system", content: input.system },
+ { role: "user", content: input.user },
+ ],
+ {
+ abortSignal: input.signal,
+ structuredOutput: { schema: schemaFor(input.sectionId) as z.ZodType, jsonPromptInjection: "inline" },
+ },
+ );
+ const usage = await Promise.resolve((result as { usage?: { inputTokens?: number; outputTokens?: number } }).usage);
+ return {
+ body: (result as { object?: unknown }).object,
+ inputTokens: Math.max(0, usage?.inputTokens ?? 0),
+ outputTokens: Math.max(0, usage?.outputTokens ?? 0),
+ };
+ };
+}
diff --git a/frontend/supabase/migrations/20261019010000_synastry_full_report.sql b/frontend/supabase/migrations/20261019010000_synastry_full_report.sql
new file mode 100644
index 00000000..b2ee4f80
--- /dev/null
+++ b/frontend/supabase/migrations/20261019010000_synastry_full_report.sql
@@ -0,0 +1,260 @@
+begin;
+
+-- Synastry full report (issue #82). Adds a report type, pair columns, the
+-- billing key, and one English job per report. Nothing is dropped.
+
+alter table public.personal_reports
+ drop constraint if exists personal_reports_report_type_check;
+alter table public.personal_reports
+ add constraint personal_reports_report_type_check
+ check (report_type in ('personal_full', 'personal_thematic', 'synastry_full'));
+
+alter table public.personal_reports
+ drop constraint if exists personal_reports_schema_version_check;
+alter table public.personal_reports
+ add constraint personal_reports_schema_version_check
+ check (schema_version in ('report_document.v1', 'report_document.v2', 'synastry_report.v1'));
+
+alter table public.personal_reports
+ add column if not exists partner_chart_profile_id uuid,
+ add column if not exists subject_low text,
+ add column if not exists subject_high text,
+ add column if not exists fingerprint_low text,
+ add column if not exists fingerprint_high text,
+ add column if not exists synastry_rules_version text,
+ add column if not exists synastry_report_id uuid;
+
+alter table public.personal_reports
+ drop constraint if exists personal_reports_synastry_subject_low_check;
+alter table public.personal_reports
+ add constraint personal_reports_synastry_subject_low_check
+ check (
+ subject_low is null
+ or subject_low ~ '^(self|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
+ );
+
+alter table public.personal_reports
+ drop constraint if exists personal_reports_synastry_subject_high_check;
+alter table public.personal_reports
+ add constraint personal_reports_synastry_subject_high_check
+ check (
+ subject_high is null
+ or subject_high ~ '^(self|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
+ );
+
+alter table public.personal_reports
+ drop constraint if exists personal_reports_synastry_fingerprint_low_check;
+alter table public.personal_reports
+ add constraint personal_reports_synastry_fingerprint_low_check
+ check (fingerprint_low is null or fingerprint_low ~ '^[0-9a-f]{64}$');
+
+alter table public.personal_reports
+ drop constraint if exists personal_reports_synastry_fingerprint_high_check;
+alter table public.personal_reports
+ add constraint personal_reports_synastry_fingerprint_high_check
+ check (fingerprint_high is null or fingerprint_high ~ '^[0-9a-f]{64}$');
+
+do $fk$
+begin
+ if not exists (
+ select 1 from pg_constraint where conname = 'personal_reports_synastry_report_fk'
+ ) then
+ alter table public.personal_reports
+ add constraint personal_reports_synastry_report_fk
+ foreign key (synastry_report_id)
+ references public.synastry_reports(id)
+ on delete cascade;
+ end if;
+end
+$fk$;
+
+create unique index if not exists personal_reports_synastry_pair_ready_uidx
+ on public.personal_reports (
+ user_id,
+ subject_low,
+ subject_high,
+ fingerprint_low,
+ fingerprint_high,
+ synastry_rules_version
+ )
+ where report_type = 'synastry_full'
+ and status = 'ready'
+ and subject_low is not null
+ and subject_high is not null
+ and fingerprint_low is not null
+ and fingerprint_high is not null
+ and synastry_rules_version is not null;
+
+-- Feature-key allow lists. Constraints that mention report.full and are not
+-- already extended are rebuilt as the same feature_key check plus synastry.full.
+do $keys$
+declare
+ rec record;
+ allowed text := $$'chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra', 'synastry.full'$$;
+ defn text;
+begin
+ for rec in
+ select n.nspname, c.relname, con.conname, pg_get_constraintdef(con.oid) as defn
+ from pg_constraint con
+ join pg_class c on c.oid = con.conrelid
+ join pg_namespace n on n.oid = c.relnamespace
+ where n.nspname = 'public'
+ and con.contype = 'c'
+ and pg_get_constraintdef(con.oid) like '%''report.full''%'
+ and pg_get_constraintdef(con.oid) not like '%synastry.full%'
+ and pg_get_constraintdef(con.oid) like '%feature_key%'
+ loop
+ execute format('alter table %I.%I drop constraint %I', rec.nspname, rec.relname, rec.conname);
+ execute format(
+ 'alter table %I.%I add constraint %I check (feature_key in (%s))',
+ rec.nspname, rec.relname, rec.conname, allowed
+ );
+ end loop;
+
+ for rec in
+ select p.oid
+ from pg_proc p
+ join pg_namespace n on n.oid = p.pronamespace
+ where n.nspname = 'public'
+ and p.proname in ('resolve_feature_pricing', 'admin_save_feature_pricing_draft')
+ loop
+ defn := pg_get_functiondef(rec.oid);
+ defn := replace(
+ defn,
+ $$'chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra'$$,
+ $$'chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra', 'synastry.full'$$
+ );
+ if defn not like '%synastry.full%' then
+ raise exception 'feature key allow-list was not rewritten';
+ end if;
+ execute defn;
+ end loop;
+end
+$keys$;
+
+create table if not exists public.synastry_english_jobs (
+ report_id uuid primary key references public.personal_reports(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ status text not null default 'queued' check (status in ('queued', 'running', 'ready', 'failed')),
+ attempt_count integer not null default 0 check (attempt_count >= 0),
+ max_attempts integer not null default 3 check (max_attempts between 1 and 10),
+ lease_token uuid,
+ lease_expires_at timestamptz,
+ last_error_code text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ check (attempt_count <= max_attempts)
+);
+
+alter table public.synastry_english_jobs enable row level security;
+revoke all on table public.synastry_english_jobs from public, anon, authenticated, service_role;
+grant select on table public.synastry_english_jobs to authenticated;
+grant select, insert, update, delete on table public.synastry_english_jobs to service_role;
+
+drop policy if exists synastry_english_jobs_select_own on public.synastry_english_jobs;
+create policy synastry_english_jobs_select_own
+ on public.synastry_english_jobs
+ for select
+ to authenticated
+ using (auth.uid() = user_id);
+
+-- Count personal reports and synastry reports separately. Deleting either
+-- person removes both the personal rows that cite them and the match row.
+drop function if exists public.chart_subject_usage(uuid);
+create function public.chart_subject_usage(p_id uuid)
+returns table (session_count integer, report_count integer, synastry_count integer)
+language plpgsql
+security invoker
+set search_path = ''
+as $$
+declare
+ owner uuid := (select auth.uid());
+begin
+ if owner is null then
+ raise exception 'chart_subject_unauthenticated';
+ end if;
+ if not exists (
+ select 1 from public.chart_profiles
+ where id = p_id and user_id = owner and role = 'other'
+ ) then
+ raise exception 'chart_subject_not_found';
+ end if;
+ session_count := (
+ select count(*)::integer from public.chat_sessions
+ where user_id = owner and chart_profile_id = p_id::text
+ );
+ report_count := (
+ select count(*)::integer from public.personal_reports
+ where user_id = owner
+ and chart_profile_id = p_id
+ and report_type <> 'synastry_full'
+ );
+ synastry_count := (
+ select count(*)::integer from public.personal_reports
+ where user_id = owner
+ and report_type = 'synastry_full'
+ and (
+ chart_profile_id = p_id
+ or partner_chart_profile_id = p_id
+ or subject_low = p_id::text
+ or subject_high = p_id::text
+ )
+ );
+ return next;
+end;
+$$;
+
+create or replace function public.delete_chart_subject(p_id uuid)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ owner uuid := (select auth.uid());
+begin
+ if owner is null then raise exception 'chart_subject_unauthenticated'; end if;
+ perform 1 from public.chart_profiles
+ where id = p_id and user_id = owner and role = 'other' for update;
+ if not found then raise exception 'chart_subject_not_found'; end if;
+ perform 1 from public.personal_report_jobs j
+ join public.personal_reports r on r.user_id = j.user_id and r.request_id = j.request_id
+ where r.user_id = owner
+ and (
+ r.chart_profile_id = p_id
+ or r.partner_chart_profile_id = p_id
+ or r.subject_low = p_id::text
+ or r.subject_high = p_id::text
+ )
+ order by j.id for update of j;
+ perform 1 from public.personal_reports
+ where user_id = owner
+ and (
+ chart_profile_id = p_id
+ or partner_chart_profile_id = p_id
+ or subject_low = p_id::text
+ or subject_high = p_id::text
+ )
+ order by id for update;
+ delete from public.chat_sessions where user_id = owner and chart_profile_id = p_id::text;
+ delete from public.personal_reports
+ where user_id = owner
+ and (
+ chart_profile_id = p_id
+ or partner_chart_profile_id = p_id
+ or subject_low = p_id::text
+ or subject_high = p_id::text
+ );
+ delete from public.synastry_reports
+ where user_id = owner
+ and (subject_low = p_id::text or subject_high = p_id::text);
+ delete from public.chart_profiles where id = p_id and user_id = owner and role = 'other';
+end;
+$$;
+
+revoke all on function public.chart_subject_usage(uuid) from public, anon;
+revoke all on function public.delete_chart_subject(uuid) from public, anon;
+grant execute on function public.chart_subject_usage(uuid) to authenticated;
+grant execute on function public.delete_chart_subject(uuid) to authenticated;
+
+commit;
diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts
index 97b48a76..96fbf61d 100644
--- a/frontend/tests/database-local-business.test.ts
+++ b/frontend/tests/database-local-business.test.ts
@@ -289,6 +289,10 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"redemption_codes",
"reply_quality_snapshots",
"reply_ratings",
+ // 原值: reply_ratings 之后直接是 synastry_reports
+ // 新值: 中间加入 synastry_english_jobs
+ // 原因: 完整合盘的英文按需生成单独排队,不占用个人报告任务的唯一键
+ "synastry_english_jobs",
"synastry_reports",
"usage_events",
"usage_ledger",
diff --git a/frontend/tests/feature-pricing-contract.test.ts b/frontend/tests/feature-pricing-contract.test.ts
index 3ddaf724..9ac60fec 100644
--- a/frontend/tests/feature-pricing-contract.test.ts
+++ b/frontend/tests/feature-pricing-contract.test.ts
@@ -44,6 +44,7 @@ test("feature pricing admin uses readable labels while preserving stable values"
["chat.premium", "高级咨询"],
["rectification", "生时校正"],
["report.full", "完整报告"],
+ ["synastry.full", "完整合盘报告"],
["report.export", "报告导出"],
["profile.extra", "额外档案"],
["standard", "标准模型"],
diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts
new file mode 100644
index 00000000..87a8a149
--- /dev/null
+++ b/frontend/tests/synastry-full-report.test.ts
@@ -0,0 +1,135 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+import { renderToStaticMarkup } from "react-dom/server";
+import { createElement } from "react";
+
+import { SynastryReportView } from "../src/components/synastry/synastry-report-view.tsx";
+import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts";
+import { userPrompt, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts";
+import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts";
+import { buildTimelineSegments } from "../src/lib/synastry-full-timeline.ts";
+
+const sections = ["climate", "emotion", "communication", "safety", "conflict", "daily", "direction"] as const;
+
+function prose() {
+ return {
+ text: "你们之间更需要把靠近说清楚。",
+ reasons: [
+ { plain: "靠近时先确认对方听完了。", basis: "证据甲", evidence: "证据甲", label: "靠近" },
+ { plain: "忙的时候先说什么时候能接着谈。", basis: "证据乙", evidence: "证据乙", label: "节奏" },
+ ],
+ };
+}
+
+test("timeline windows stay between three and five and keep both people in the basis", () => {
+ const segments = buildTimelineSegments({
+ nameA: "甲",
+ nameB: "乙",
+ from: "2026-01-01",
+ periodsA: [
+ { level: "md", lord: "jupiter", lordCn: "木星", start: "2024-01-01", end: "2028-06-01" },
+ { level: "ad", lord: "saturn", lordCn: "土星", start: "2026-01-01", end: "2026-08-01" },
+ ],
+ periodsB: [
+ { level: "md", lord: "venus", lordCn: "金星", start: "2025-01-01", end: "2030-01-01" },
+ ],
+ });
+ assert.ok(segments.length >= 3 && segments.length <= 5);
+ assert.match(segments[0]?.basis ?? "", /甲/);
+ assert.match(segments[0]?.basis ?? "", /乙/);
+});
+
+test("english prompt does not carry the chinese section body", () => {
+ const chineseBody = "这段关系的核心不是谁对谁错,而是你们怎么把话接住。";
+ const prompt = userPrompt({
+ language: "en",
+ names: { a: "A", b: "B" },
+ sectionId: "climate",
+ evidence: { reader_section_brief: "pair climate" },
+ });
+ assert.equal(prompt.includes(chineseBody), false);
+ assert.match(prompt, /^Names: /);
+});
+
+test("a failed gate rewrites only the failed section once", async () => {
+ const calls: SynastrySectionId[] = [];
+ let checks = 0;
+ const result = await writeSynastryLanguage({
+ language: "zh",
+ names: { a: "甲", b: "乙" },
+ dossier: { execution_receipt: { individual_technique_audit: [{ technique: "Niryaana Shoola", status: "not_applicable" }] } },
+ segments: [
+ { id: "s1", start: "2026-01", end: "2027-01", basis: "甲:木星大运 2026-01 至 2027-01。乙:金星大运 2026-01 至 2027-01。" },
+ { id: "s2", start: "2027-01", end: "2028-01", basis: "甲:木星大运 2027-01 至 2028-01。乙:金星大运 2027-01 至 2028-01。" },
+ { id: "s3", start: "2028-01", end: "2029-01", basis: "甲:木星大运 2028-01 至 2029-01。乙:金星大运 2028-01 至 2029-01。" },
+ ],
+ signal: new AbortController().signal,
+ timeoutMs: 1000,
+ write: async ({ sectionId }) => {
+ calls.push(sectionId);
+ if (sectionId === "timeline") {
+ return { body: { segments: [{ id: "s1", phase: "靠近", text: "这段时间先把日常节奏说清楚。" }, { id: "s2", phase: "磨合", text: "这段时间把分歧说完再放下。" }, { id: "s3", phase: "协作", text: "这段时间一起把安排写下来。" }] }, inputTokens: 1, outputTokens: 1 };
+ }
+ if (sectionId === "practices") {
+ return { body: { items: [{ text: "每周留一次不被打断的谈话。", evidence: "证据甲" }] }, inputTokens: 1, outputTokens: 1 };
+ }
+ return { body: prose(), inputTokens: 1, outputTokens: 1 };
+ },
+ gate: async () => {
+ checks += 1;
+ return checks === 1
+ ? { ok: false, failures: [{ section: "climate", code: "term_ban" }] }
+ : { ok: true, failures: [] };
+ },
+ });
+ assert.equal(checks, 2);
+ assert.equal(calls.filter((id) => id === "climate").length, 2);
+ for (const id of sections) {
+ if (id === "climate") continue;
+ assert.equal(calls.filter((item) => item === id).length, 1);
+ }
+ assert.equal(result.snapshotParts.technical.length, 6);
+ assert.match(result.snapshotParts.technical[5]?.lines.at(-1) ?? "", /外部资料与真实案例核对:未进行/);
+});
+
+test("technical appendix has six program blocks and the fixed closing line", () => {
+ const blocks = buildTechnicalBasis({
+ dossier: { execution_receipt: { pair_execution_tasks: [{ task: "Kuja Dosha", status: "not_applicable" }] } },
+ names: { a: "甲", b: "乙" },
+ contacts: ["第七宫与金星有相位"],
+ timing: ["甲:木星大运 2026-01 至 2028-01"],
+ });
+ assert.deepEqual(blocks.map((block) => block.id), ["parameters", "structures", "contacts", "timing", "traditional", "execution"]);
+ assert.equal(blocks[5]?.lines.at(-1), "外部资料与真实案例核对:未进行,本报告是计算层归纳");
+ assert.equal(JSON.stringify(blocks).includes("1879"), false);
+});
+
+test("old synastry links open the pair page instead of a chat draft", () => {
+ const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
+ const deepLink = readFileSync(new URL("../src/components/people-home-links.tsx", import.meta.url), "utf8");
+ assert.match(people, /\/synastry\?with=\$\{selected\.id\}/);
+ assert.doesNotMatch(people, /\?synastry=\$\{selected\.id\}/);
+ assert.match(deepLink, /location\.replace\(`\/synastry\?with=/);
+ assert.doesNotMatch(deepLink, /draftSynastryQuestionFromChart|relationship: "other"/);
+});
+
+test("report view keeps analysis folded and hides the ask entry", () => {
+ const html = renderToStaticMarkup(createElement(SynastryReportView, {
+ reportId: "11111111-1111-4111-8111-111111111111",
+ snapshot: {
+ names: { a: "甲", b: "乙" },
+ sections: sections.map((id) => ({ id, title: id, text: "你们之间先把话说完。", reasons: prose().reasons })),
+ timeline: [
+ { id: "s1", phase: "靠近", text: "先把日常说清楚。", basis: "甲:木星大运。" },
+ ],
+ practices: [{ text: "每周谈一次。", evidence: "证据甲" }],
+ technical: buildTechnicalBasis({ dossier: {}, names: { a: "甲", b: "乙" }, contacts: [], timing: [] }),
+ },
+ }));
+ assert.match(html, //);
+ assert.equal(html.includes(" str:
return os.environ.get("GITHUB_SHA") or ""
+def check_synastry_reader(body: dict[str, Any]) -> dict[str, Any]:
+ """Relationship-language gate. The body is the delivery plus the section inputs."""
+ if not isinstance(body, dict):
+ raise MarriageMatchInputError("request body must be an object")
+ from marriage_match_service.synastry_full_reader_gate import validate_synastry_reader
+
+ delivery = body.get("delivery")
+ section_inputs = body.get("sectionInputs")
+ language = body.get("language") or "zh"
+ if not isinstance(delivery, dict) or not isinstance(language, str):
+ raise MarriageMatchInputError("delivery and language are required")
+ if section_inputs is not None and not isinstance(section_inputs, dict):
+ raise MarriageMatchInputError("sectionInputs must be an object")
+ return validate_synastry_reader(delivery, section_inputs or {}, language)
+
+
def compute_synastry_full_dossier(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
if not isinstance(body, dict):
raise MarriageMatchInputError("request body must be an object")
diff --git a/scripts/marriage_match_service/full_dossier.py b/scripts/marriage_match_service/full_dossier.py
index b4096cd3..f772b285 100644
--- a/scripts/marriage_match_service/full_dossier.py
+++ b/scripts/marriage_match_service/full_dossier.py
@@ -9,6 +9,7 @@ from __future__ import annotations
import importlib.util
from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable
@@ -41,6 +42,60 @@ def _dossier_builder():
return module.build_full_synastry_dossier
+def _parse_day(value: Any) -> datetime | None:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ text = value.strip()[:10]
+ try:
+ return datetime.strptime(text, "%Y-%m-%d")
+ except ValueError:
+ return None
+
+
+def _clip_period(period: dict[str, Any], level: str, window_start: datetime, window_end: datetime) -> dict[str, Any] | None:
+ start = _parse_day(period.get("start") or period.get("start_datetime"))
+ end = _parse_day(period.get("end") or period.get("end_datetime"))
+ if start is None or end is None or end <= window_start or start >= window_end:
+ return None
+ lord = period.get("lord")
+ if not isinstance(lord, str) or not lord:
+ return None
+ lord_cn = period.get("lord_cn") if isinstance(period.get("lord_cn"), str) else lord
+ return {
+ "level": level,
+ "lord": lord,
+ "lordCn": lord_cn,
+ "start": max(start, window_start).strftime("%Y-%m-%d"),
+ "end": min(end, window_end).strftime("%Y-%m-%d"),
+ }
+
+
+def upcoming_vimshottari(reading: dict[str, Any], today: str, years: int = 5) -> list[dict[str, Any]]:
+ """Mahadasha and antardasha slices that overlap the next few years."""
+ packet = reading.get("raw_full_reading") if isinstance(reading.get("raw_full_reading"), dict) else reading
+ modules = packet.get("modules") if isinstance(packet, dict) else {}
+ dasha = modules.get("dasha") if isinstance(modules, dict) else {}
+ timeline = dasha.get("timeline") if isinstance(dasha, dict) else []
+ start = _parse_day(today) or datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
+ end = start + timedelta(days=365 * years)
+ rows: list[dict[str, Any]] = []
+ if not isinstance(timeline, list):
+ return rows
+ for mahadasha in timeline:
+ if not isinstance(mahadasha, dict):
+ continue
+ clipped = _clip_period(mahadasha, "md", start, end)
+ if clipped:
+ rows.append(clipped)
+ for antardasha in mahadasha.get("antardasha_timeline") or []:
+ if not isinstance(antardasha, dict):
+ continue
+ child = _clip_period(antardasha, "ad", start, end)
+ if child:
+ rows.append(child)
+ return rows
+
+
def build_synastry_full_dossier(
handler: Any,
person_a: dict[str, Any],
@@ -56,6 +111,7 @@ def build_synastry_full_dossier(
reading_a = future_a.result()
reading_b = future_b.result()
dossier = _dossier_builder()(reading_a, reading_b)
+ today = str(person_a.get("today") or person_b.get("today") or "")
snapshots = dossier.get("chart_snapshots") if isinstance(dossier, dict) else {}
snapshots = snapshots if isinstance(snapshots, dict) else {}
raman = build_marriage_match_dossier(
@@ -67,4 +123,8 @@ def build_synastry_full_dossier(
"rules_version": RULES_VERSION,
"dossier": dossier,
"raman_marriage_match": raman,
+ "timing_periods": {
+ "person_a": upcoming_vimshottari(reading_a if isinstance(reading_a, dict) else {}, today),
+ "person_b": upcoming_vimshottari(reading_b if isinstance(reading_b, dict) else {}, today),
+ },
}
diff --git a/scripts/marriage_match_service/synastry_full_reader_gate.py b/scripts/marriage_match_service/synastry_full_reader_gate.py
new file mode 100644
index 00000000..55142b6d
--- /dev/null
+++ b/scripts/marriage_match_service/synastry_full_reader_gate.py
@@ -0,0 +1,290 @@
+"""Relationship-language gate for the full synastry report.
+
+The upstream bilingual validator stays untouched. This gate only borrows its
+private-evidence and supplementary-method lists. It judges the reader text
+against the relationship-language guide, and it checks that each "盘面" line
+matches the evidence it cites.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parents[2]
+SKILL_SCRIPTS = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts"
+if str(SKILL_SCRIPTS) not in sys.path:
+ sys.path.insert(0, str(SKILL_SCRIPTS))
+
+from validate_bilingual_synastry_delivery import ( # noqa: E402
+ PRIVATE_EVIDENCE_TERMS,
+ SUPPLEMENTARY_METHOD_TERMS,
+)
+
+SECTION_ORDER = (
+ "climate",
+ "emotion",
+ "communication",
+ "safety",
+ "conflict",
+ "daily",
+ "direction",
+ "timeline",
+ "practices",
+)
+PROSE_SECTIONS = SECTION_ORDER[:7]
+EVIDENCE_LABELS = (
+ "multi_system_consensus",
+ "single_system_inference",
+ "parameter_sensitive",
+ "unclosed_divisional_chart",
+ "user_history_verification_required",
+ "blocked",
+)
+
+ZH_PLANETS = ("太阳", "月亮", "水星", "金星", "火星", "木星", "土星", "罗睺", "计都")
+ZH_SIGNS = (
+ "白羊", "金牛", "双子", "巨蟹", "狮子", "处女",
+ "天秤", "天蝎", "射手", "摩羯", "水瓶", "双鱼",
+)
+ZH_TERMS = (
+ *ZH_PLANETS,
+ *ZH_SIGNS,
+ "宫", "星宿", "上升", "分盘", "九分盘", "婚姻分盘",
+ "大运", "小运", "行运", "Dasha", "Koota", "Kuja", "火星冲克",
+ "Nadi", "Yoni", "Varna", "Ashtakoota", "Papasamya", "UL", "DK", "满分",
+)
+ZH_JUDGEMENTS = (
+ "克", "煞", "凶", "吉", "注定", "必然", "命中", "一定会",
+ "合不合", "不合", "相合", "不适合", "适合结婚", "不宜",
+ "灵魂伴侣", "宇宙安排", "百分百", "会不会在一起",
+)
+EN_PLANETS = ("sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn", "rahu", "ketu")
+EN_SIGNS = (
+ "aries", "taurus", "gemini", "cancer", "leo", "virgo",
+ "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
+)
+EN_TERMS = (
+ *EN_PLANETS,
+ *EN_SIGNS,
+ "house", "nakshatra", "ascendant", "navamsa", "dasha", "transit", "koota",
+)
+EN_JUDGEMENTS = (
+ "soulmate", "destined", "inevitable", "guaranteed", "the universe",
+ "not suitable", "will marry", "will divorce",
+)
+ZH_OPENING = ("这段关系的核心不是", "不是有没有")
+EN_OPENING = ("the core of this relationship is not", "it is not whether")
+ZH_EVENT = re.compile(r"(19|20)\d{2}\s*年.{0,12}(结婚|领证|分手|离婚)")
+EN_EVENT = re.compile(r"\b(19|20)\d{2}\b.{0,24}\b(marry|married|marriage|divorce|break up)\b", re.I)
+ZH_SCORE = re.compile(r"\d+\s*分")
+EN_SCORE = re.compile(r"\b\d+\s*(points|point|score)\b", re.I)
+ZH_HOUSE = re.compile(r"(?:第)?[一二三四五六七八九十\d]{1,3}宫")
+EN_HOUSE = re.compile(r"\b(?:house\s*)?\d{1,2}(?:st|nd|rd|th)?\s+house\b", re.I)
+D_CHART = re.compile(r"\bD\d{1,2}\b", re.I)
+
+
+def _fail(section: str, code: str, detail: str) -> dict[str, str]:
+ return {"section": section, "code": code, "detail": detail}
+
+
+def _contains_any(text: str, terms: tuple[str, ...]) -> str | None:
+ for term in terms:
+ if term and term in text:
+ return term
+ return None
+
+
+def _contains_word(text: str, words: tuple[str, ...]) -> str | None:
+ lowered = text.lower()
+ for word in words:
+ if re.search(rf"\b{re.escape(word.lower())}\b", lowered):
+ return word
+ return None
+
+
+def _first_sentence(text: str) -> str:
+ match = re.split(r"[。!?.!?]", text.strip(), maxsplit=1)
+ return match[0] if match else text.strip()
+
+
+def _basis_tokens(basis: str, language: str) -> list[str]:
+ tokens: list[str] = []
+ if language == "zh":
+ tokens.extend(term for term in ZH_PLANETS if term in basis)
+ tokens.extend(match.group(0) for match in ZH_HOUSE.finditer(basis))
+ if "大运" in basis:
+ tokens.append("大运")
+ if "小运" in basis:
+ tokens.append("小运")
+ else:
+ tokens.extend(word for word in EN_PLANETS if re.search(rf"\b{word}\b", basis, re.I))
+ tokens.extend(match.group(0) for match in EN_HOUSE.finditer(basis))
+ if re.search(r"\bdasha\b", basis, re.I):
+ tokens.append("dasha")
+ tokens.extend(match.group(0) for match in D_CHART.finditer(basis))
+ return tokens
+
+
+def _input_text(section_inputs: dict[str, Any], section_id: str) -> str:
+ value = section_inputs.get(section_id, "")
+ if isinstance(value, str):
+ return value
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _scan_prose(text: str, language: str, section: str, *, opening: bool) -> list[dict[str, str]]:
+ failures: list[dict[str, str]] = []
+ private = _contains_any(text, PRIVATE_EVIDENCE_TERMS)
+ supplementary = _contains_any(text, SUPPLEMENTARY_METHOD_TERMS)
+ if private:
+ failures.append(_fail(section, "private_term", private))
+ if supplementary:
+ failures.append(_fail(section, "supplementary_method", supplementary))
+ if language == "zh":
+ term = _contains_any(text, ZH_TERMS)
+ judgement = _contains_any(text, ZH_JUDGEMENTS)
+ if term:
+ failures.append(_fail(section, "banned_term", term))
+ if judgement:
+ failures.append(_fail(section, "banned_judgement", judgement))
+ if ZH_SCORE.search(text):
+ failures.append(_fail(section, "score", "数字分"))
+ if ZH_EVENT.search(text):
+ failures.append(_fail(section, "dated_event", "年份事件"))
+ if D_CHART.search(text):
+ failures.append(_fail(section, "banned_term", "D盘"))
+ if opening and _contains_any(_first_sentence(text), ZH_OPENING):
+ failures.append(_fail(section, "analytic_opening", _first_sentence(text)))
+ else:
+ term = _contains_word(text, EN_TERMS)
+ judgement = _contains_word(text, EN_JUDGEMENTS)
+ if term:
+ failures.append(_fail(section, "banned_term", term or ""))
+ if judgement:
+ failures.append(_fail(section, "banned_judgement", judgement or ""))
+ if EN_SCORE.search(text):
+ failures.append(_fail(section, "score", "score"))
+ if EN_EVENT.search(text):
+ failures.append(_fail(section, "dated_event", "dated event"))
+ if D_CHART.search(text):
+ failures.append(_fail(section, "banned_term", "D chart"))
+ if opening and _contains_word(_first_sentence(text), EN_OPENING):
+ failures.append(_fail(section, "analytic_opening", _first_sentence(text)))
+ return failures
+
+
+def validate_synastry_reader(
+ delivery: dict[str, Any],
+ section_inputs: dict[str, Any] | None = None,
+ language: str = "zh",
+) -> dict[str, Any]:
+ """Return ``{ok, failures}``. ``section_inputs`` is what the model was shown."""
+ if language not in {"zh", "en"}:
+ return {"ok": False, "failures": [_fail("delivery", "language", language)]}
+ inputs = section_inputs or {}
+ failures: list[dict[str, str]] = []
+ sections = delivery.get("sections")
+ if not isinstance(sections, list):
+ return {"ok": False, "failures": [_fail("delivery", "sections", "missing")]}
+ by_id = {
+ str(row.get("id")): row
+ for row in sections
+ if isinstance(row, dict) and row.get("id")
+ }
+ if [row for row in SECTION_ORDER if row not in by_id] or [str(row.get("id")) for row in sections] != list(SECTION_ORDER):
+ failures.append(_fail("delivery", "section_order", "九节顺序不对"))
+
+ prose_characters = 0
+ prose_words = 0
+ for section_id in PROSE_SECTIONS:
+ row = by_id.get(section_id)
+ if not isinstance(row, dict):
+ failures.append(_fail(section_id, "missing_section", section_id))
+ continue
+ text = str(row.get("text") or "")
+ prose_characters += len(re.sub(r"\s+", "", text))
+ prose_words += len(re.findall(r"\b[\w']+\b", text))
+ minimum = 120 if language == "zh" else 80
+ measured = len(re.sub(r"\s+", "", text)) if language == "zh" else len(re.findall(r"\b[\w']+\b", text))
+ if measured < minimum:
+ failures.append(_fail(section_id, "too_short", str(measured)))
+ failures.extend(_scan_prose(text, language, section_id, opening=section_id == "climate"))
+ reasons = row.get("reasons")
+ if not isinstance(reasons, list) or not 2 <= len(reasons) <= 3:
+ failures.append(_fail(section_id, "reason_count", "需要两到三条分析"))
+ continue
+ source = _input_text(inputs, section_id)
+ for index, reason in enumerate(reasons):
+ if not isinstance(reason, dict):
+ failures.append(_fail(section_id, "reason_shape", str(index)))
+ continue
+ plain = str(reason.get("plain") or "")
+ basis = str(reason.get("basis") or "")
+ evidence = str(reason.get("evidence") or "")
+ label = str(reason.get("label") or "")
+ failures.extend(_scan_prose(plain, language, section_id, opening=False))
+ if not evidence or evidence not in source:
+ failures.append(_fail(section_id, "evidence_missing", evidence or str(index)))
+ if label not in EVIDENCE_LABELS:
+ failures.append(_fail(section_id, "evidence_label", label or str(index)))
+ for token in _basis_tokens(basis, language):
+ if token.lower() not in evidence.lower() and token not in evidence:
+ failures.append(_fail(section_id, "basis_mismatch", token))
+
+ timeline = by_id.get("timeline") if isinstance(by_id.get("timeline"), dict) else {}
+ segments = timeline.get("segments") if isinstance(timeline, dict) else None
+ if not isinstance(segments, list) or not 3 <= len(segments) <= 5:
+ failures.append(_fail("timeline", "segment_count", "需要三到五段"))
+ else:
+ for segment in segments:
+ if not isinstance(segment, dict):
+ failures.append(_fail("timeline", "segment_shape", "bad"))
+ continue
+ description = str(segment.get("text") or "")
+ failures.extend(_scan_prose(description, language, "timeline", opening=False))
+ if not str(segment.get("basis") or "").strip():
+ failures.append(_fail("timeline", "basis_missing", str(segment.get("id") or "")))
+
+ practices = by_id.get("practices") if isinstance(by_id.get("practices"), dict) else {}
+ items = practices.get("items") if isinstance(practices, dict) else None
+ if not isinstance(items, list) or not items:
+ failures.append(_fail("practices", "missing_items", "需要共同练习"))
+ else:
+ practice_source = _input_text(inputs, "practices")
+ for item in items:
+ if not isinstance(item, dict):
+ failures.append(_fail("practices", "item_shape", "bad"))
+ continue
+ text = str(item.get("text") or "")
+ evidence = str(item.get("evidence") or "")
+ failures.extend(_scan_prose(text, language, "practices", opening=False))
+ if not evidence or evidence not in practice_source:
+ failures.append(_fail("practices", "evidence_missing", evidence or "item"))
+
+ total_minimum = 1800 if language == "zh" else 1100
+ total = prose_characters if language == "zh" else prose_words
+ if total < total_minimum:
+ failures.append(_fail("delivery", "too_short", str(total)))
+ return {"ok": not failures, "failures": failures}
+
+
+def main() -> None:
+ payload = json.load(sys.stdin)
+ if not isinstance(payload, dict):
+ raise SystemExit("payload must be an object")
+ result = validate_synastry_reader(
+ payload.get("delivery") if isinstance(payload.get("delivery"), dict) else {},
+ payload.get("sectionInputs") if isinstance(payload.get("sectionInputs"), dict) else {},
+ str(payload.get("language") or "zh"),
+ )
+ json.dump(result, sys.stdout, ensure_ascii=False)
+ if not result["ok"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/jyotish-full-synastry-reading/SKILL.md b/skills/jyotish-full-synastry-reading/SKILL.md
index 5423ac36..99a5e3e7 100644
--- a/skills/jyotish-full-synastry-reading/SKILL.md
+++ b/skills/jyotish-full-synastry-reading/SKILL.md
@@ -222,29 +222,20 @@ Markdown reports** from the same frozen dossier. Write Chinese under
English composition pass under
`references/english-synastry-writing-contract.md`. English must use the
structured dossier, original placements and original citations; never translate
-the Chinese draft. Store the two independently written section payloads outside
-the repository. Each payload declares `language`,
-`composition: independent_from_structured_dossier`, an optional `title`, and
-all nine stable section keys from the private `reader_section_brief`. Compile
-and gate the delivery in one private step:
+the Chinese draft. Run the private delivery gate after both files exist:
```bash
-python3 skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py \
+python3 skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py \
--dossier /private/runtime/full-synastry-dossier.json \
- --chinese-sections /private/runtime/chinese-sections.json \
- --english-sections /private/runtime/english-sections.json \
- --output-dir /private/runtime/synastry-delivery
+ --chinese /private/runtime/Chinese-Full-Synastry-Report.md \
+ --english /private/runtime/English-Full-Synastry-Report.md \
+ --output /private/runtime/bilingual-delivery-receipt.json
```
-Use `--dry-run` first when reviewing a draft. It validates the complete
-delivery in memory and prints `would-deliver`; it does not create a directory
-or any report artifact.
-
-The compiler writes Chinese Markdown, English Markdown and the private receipt
-only after the gate passes. The receipt checks shared evidence coverage,
-direct-English composition metadata, terminology, section-by-section narrative
-depth, reciprocal-care coverage and report shape. It does not justify copying a
-Chinese paragraph into English.
+The receipt checks shared evidence coverage, direct-English composition
+metadata, terminology, section-by-section narrative depth, reciprocal-care
+coverage and report shape. It does not justify copying a Chinese paragraph into
+English.
## Boundaries
diff --git a/skills/jyotish-full-synastry-reading/agents/openai.yaml b/skills/jyotish-full-synastry-reading/agents/openai.yaml
index e6f1e98b..aa51b248 100644
--- a/skills/jyotish-full-synastry-reading/agents/openai.yaml
+++ b/skills/jyotish-full-synastry-reading/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Full Jyotish Synastry Reading"
short_description: "Narrative love and marriage chart interpretation"
- default_prompt: "Use $jyotish-full-synastry-reading for a full love/marriage synastry reading. Ask only for each person's local birth date, local birth time, and full birthplace when missing; resolve coordinates, calculate both charts under one visible profile, run the private full-synastry dossier, and write independently composed Chinese and English reports. Compile the two private section payloads through compose_full_synastry_delivery.py so every reader section passes the delivery gate. Lead with lived interaction, each person's distinct experience, progression, shared gains/pressures, and event-class-specific timing. Do not turn Ashtakoota, Kuja, or coincident Dasha dates into a score, verdict, or shared relationship event."
+ default_prompt: "Use $jyotish-full-synastry-reading for a full love/marriage synastry reading. Ask only for each person's local birth date, local birth time, and full birthplace when missing; resolve coordinates, calculate both charts under one visible profile, run the private full-synastry dossier, and write independently composed Chinese and English reports. Lead with lived interaction, each person's distinct experience, progression, shared gains/pressures, and event-class-specific timing. Do not turn Ashtakoota, Kuja, or coincident Dasha dates into a score, verdict, or shared relationship event."
diff --git a/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py b/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py
index a06cf37f..7f9375c4 100644
--- a/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py
+++ b/skills/jyotish-full-synastry-reading/scripts/compose_full_synastry_delivery.py
@@ -118,6 +118,21 @@ def compile_delivery(
return {"chinese_markdown": chinese, "english_markdown": english, "delivery_receipt": receipt}
+def compile_single_language_delivery(
+ dossier: Mapping[str, Any], payload: Mapping[str, Any], language: str,
+) -> dict[str, Any]:
+ """Compile one independently authored language from the frozen dossier."""
+ if language not in {"zh", "en"}:
+ raise ValueError("language must be 'zh' or 'en'")
+ _dossier_ready(dossier)
+ headings = ZH_HEADINGS if language == "zh" else EN_HEADINGS
+ markdown = _render_markdown(_payload(payload, language=language), headings)
+ receipt = _VALIDATOR.build_single_language_delivery_receipt(dossier, markdown, language)
+ if receipt.get("status") != "passed":
+ raise ValueError("single-language synastry delivery gate failed; inspect the private receipt before writing any report")
+ return {"language": language, "markdown": markdown, "delivery_receipt": receipt}
+
+
def _publish_delivery_bundle(output_dir: Path, compiled: Mapping[str, Any]) -> None:
"""Publish all three files together; never overwrite an existing delivery."""
if output_dir.exists():
@@ -137,21 +152,57 @@ def _publish_delivery_bundle(output_dir: Path, compiled: Mapping[str, Any]) -> N
raise
+def _publish_single_language_delivery(output_dir: Path, compiled: Mapping[str, Any]) -> None:
+ """Atomically publish one language without creating a fictional peer report."""
+ if output_dir.exists():
+ raise ValueError("--output-dir already exists; choose a new private release directory")
+ output_dir.parent.mkdir(parents=True, exist_ok=True)
+ staging = Path(tempfile.mkdtemp(prefix=".synastry-delivery-", dir=output_dir.parent))
+ language = str(compiled["language"])
+ report_name = "Chinese-Full-Synastry-Report.md" if language == "zh" else "English-Full-Synastry-Report.md"
+ receipt_name = f"{language}-delivery-receipt.json"
+ try:
+ (staging / report_name).write_text(compiled["markdown"], encoding="utf-8")
+ (staging / receipt_name).write_text(
+ json.dumps(compiled["delivery_receipt"], ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ staging.replace(output_dir)
+ except BaseException:
+ shutil.rmtree(staging, ignore_errors=True)
+ raise
+
+
def main() -> int:
parser = argparse.ArgumentParser(description="Compile private, independently written full-synastry Markdown delivery.")
parser.add_argument("--dossier", required=True, help="Private full-synastry dossier JSON")
- parser.add_argument("--chinese-sections", required=True, help="Private independent Chinese section payload JSON")
- parser.add_argument("--english-sections", required=True, help="Private independent English section payload JSON")
+ parser.add_argument("--chinese-sections", help="Private independent Chinese section payload JSON")
+ parser.add_argument("--english-sections", help="Private independent English section payload JSON")
+ parser.add_argument("--language", choices=("zh", "en"), help="Compile one independently authored language")
+ parser.add_argument("--sections", help="Private section payload JSON used with --language")
parser.add_argument("--output-dir", required=True, help="Private directory outside the repository")
parser.add_argument("--dry-run", action="store_true", help="Validate and print the intended delivery without writing files")
args = parser.parse_args()
output_dir = _private_output_directory(args.output_dir)
dossier = json.loads(Path(args.dossier).read_text(encoding="utf-8"))
- chinese_payload = json.loads(Path(args.chinese_sections).read_text(encoding="utf-8"))
- english_payload = json.loads(Path(args.english_sections).read_text(encoding="utf-8"))
- compiled = compile_delivery(dossier, chinese_payload, english_payload)
- files = ["Chinese-Full-Synastry-Report.md", "English-Full-Synastry-Report.md", "bilingual-delivery-receipt.json"]
+ if args.language:
+ if not args.sections or args.chinese_sections or args.english_sections:
+ parser.error("--language requires --sections and cannot be combined with bilingual section arguments")
+ compiled = compile_single_language_delivery(
+ dossier, json.loads(Path(args.sections).read_text(encoding="utf-8")), args.language,
+ )
+ files = [
+ "Chinese-Full-Synastry-Report.md" if args.language == "zh" else "English-Full-Synastry-Report.md",
+ f"{args.language}-delivery-receipt.json",
+ ]
+ else:
+ if not args.chinese_sections or not args.english_sections:
+ parser.error("bilingual delivery requires --chinese-sections and --english-sections")
+ chinese_payload = json.loads(Path(args.chinese_sections).read_text(encoding="utf-8"))
+ english_payload = json.loads(Path(args.english_sections).read_text(encoding="utf-8"))
+ compiled = compile_delivery(dossier, chinese_payload, english_payload)
+ files = ["Chinese-Full-Synastry-Report.md", "English-Full-Synastry-Report.md", "bilingual-delivery-receipt.json"]
if args.dry_run:
print(json.dumps({
"status": "would-deliver",
@@ -160,7 +211,10 @@ def main() -> int:
"receipt_status": compiled["delivery_receipt"]["status"],
}, ensure_ascii=False))
return 0
- _publish_delivery_bundle(output_dir, compiled)
+ if args.language:
+ _publish_single_language_delivery(output_dir, compiled)
+ else:
+ _publish_delivery_bundle(output_dir, compiled)
print(json.dumps({
"status": "delivered",
"output_dir": str(output_dir),
diff --git a/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py b/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py
index c0c98d60..0441cc97 100644
--- a/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py
+++ b/skills/jyotish-full-synastry-reading/scripts/prepare_full_synastry_packets.py
@@ -179,7 +179,7 @@ def main() -> int:
print(json.dumps({
"status": "prepared", "output_dir": str(output_dir),
"dossier_status": prepared["dossier"].get("delivery_readiness"),
- "next_step": "Write independent Chinese and English section payloads from dossier.json, then run compose_full_synastry_delivery.py.",
+ "next_step": "Write Chinese and English reports independently from dossier.json, then run validate_bilingual_synastry_delivery.py.",
}, ensure_ascii=False))
return 0
diff --git a/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py b/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py
index bb658ee8..6c34df8c 100644
--- a/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py
+++ b/skills/jyotish-full-synastry-reading/scripts/validate_bilingual_synastry_delivery.py
@@ -15,6 +15,7 @@ from typing import Any, Mapping
SCHEMA = "jyotish.full_synastry_bilingual_delivery_receipt.v1"
+SINGLE_LANGUAGE_SCHEMA = "jyotish.full_synastry_single_language_delivery_receipt.v1"
ZH_HEADINGS = ("结论总纲", "各自带入关系的需求", "情绪安全与相处感受", "吸引与亲密", "沟通、冲突与修复", "关系如何推进", "共同生活的收益与压力", "能不能稳定走下去", "关系应期")
EN_HEADINGS = ("Relationship Thesis", "What Each Person Brings", "Emotional Safety and Lived Experience", "Attraction and Closeness", "Communication, Conflict and Repair", "How the Relationship Progresses", "Shared Life: Gains and Pressures", "Can It Stay Steady?", "Relationship Weather")
EVIDENCE_LABELS = (
@@ -209,10 +210,7 @@ def build_delivery_receipt(dossier: Mapping[str, Any], chinese: str, english: st
return {
"schema": SCHEMA,
"dossier_schema": dossier.get("schema"),
- "composition": {
- "chinese": "direct_from_structured_dossier",
- "english": "direct_from_structured_dossier_not_translated_from_chinese",
- },
+ "composition": {"chinese": "direct_from_structured_dossier", "english": "direct_from_structured_dossier_not_translated_from_chinese"},
"headings": {"chinese": zh_headings, "english": en_headings},
"evidence_labels": cited_labels,
"source_map_status": source_status,
@@ -222,60 +220,122 @@ def build_delivery_receipt(dossier: Mapping[str, Any], chinese: str, english: st
"opening_affection_coverage": opening_affection,
"section_depth_coverage": section_depth,
"attraction_reciprocity_coverage": attraction_reciprocity,
- "supplementary_method_terms": {
- "chinese": {term: term in chinese for term in SUPPLEMENTARY_METHOD_TERMS},
- "english": {term: term in english for term in SUPPLEMENTARY_METHOD_TERMS},
- },
- "private_evidence_terms": {
- "chinese": {term: term in chinese for term in PRIVATE_EVIDENCE_TERMS},
- "english": {term: term in english for term in PRIVATE_EVIDENCE_TERMS},
- },
+ "supplementary_method_terms": {"chinese": {term: term in chinese for term in SUPPLEMENTARY_METHOD_TERMS}, "english": {term: term in english for term in SUPPLEMENTARY_METHOD_TERMS}},
+ "private_evidence_terms": {"chinese": {term: term in chinese for term in PRIVATE_EVIDENCE_TERMS}, "english": {term: term in english for term in PRIVATE_EVIDENCE_TERMS}},
"english_cjk_ratio": round(english_cjk_ratio, 5),
- "reader_depth": {
- "chinese_characters": chinese_characters,
- "chinese_minimum": ZH_READER_MIN_CHARACTERS,
- "english_words": english_words,
- "english_minimum": EN_READER_MIN_WORDS,
- "status": "passed" if chinese_characters >= ZH_READER_MIN_CHARACTERS and english_words >= EN_READER_MIN_WORDS else "review_required",
- },
- "reader_language_terms": {
- "chinese": {term: term in chinese for term in READER_BANNED_TERMS},
- "english": {term: term in english.lower() for term in READER_BANNED_TERMS},
- },
+ "reader_depth": {"chinese_characters": chinese_characters, "chinese_minimum": ZH_READER_MIN_CHARACTERS, "english_words": english_words, "english_minimum": EN_READER_MIN_WORDS, "status": "passed" if chinese_characters >= ZH_READER_MIN_CHARACTERS and english_words >= EN_READER_MIN_WORDS else "review_required"},
+ "reader_language_terms": {"chinese": {term: term in chinese for term in READER_BANNED_TERMS}, "english": {term: term in english.lower() for term in READER_BANNED_TERMS}},
"status": "passed" if (
- dossier.get("delivery_readiness") == "ready_for_narrative"
- and all(zh_headings.values())
- and all(en_headings.values())
- and opening_quality["status"] == "passed"
- and opening_affection["status"] == "passed"
- and section_depth["status"] == "passed"
- and attraction_reciprocity["status"] == "passed"
+ dossier.get("delivery_readiness") == "ready_for_narrative" and all(zh_headings.values()) and all(en_headings.values())
+ and opening_quality["status"] == "passed" and opening_affection["status"] == "passed"
+ and section_depth["status"] == "passed" and attraction_reciprocity["status"] == "passed"
and (not d9_required or (d9_coverage["chinese"] and d9_coverage["english"]))
- and not any(term in chinese for term in SUPPLEMENTARY_METHOD_TERMS)
- and not any(term in english for term in SUPPLEMENTARY_METHOD_TERMS)
- and not any(term in chinese for term in PRIVATE_EVIDENCE_TERMS)
- and not any(term in english for term in PRIVATE_EVIDENCE_TERMS)
- and chinese_characters >= ZH_READER_MIN_CHARACTERS
- and english_words >= EN_READER_MIN_WORDS
- and not any(term in chinese for term in READER_BANNED_TERMS)
- and not any(term in english.lower() for term in READER_BANNED_TERMS)
+ and not any(term in chinese for term in SUPPLEMENTARY_METHOD_TERMS) and not any(term in english for term in SUPPLEMENTARY_METHOD_TERMS)
+ and not any(term in chinese for term in PRIVATE_EVIDENCE_TERMS) and not any(term in english for term in PRIVATE_EVIDENCE_TERMS)
+ and chinese_characters >= ZH_READER_MIN_CHARACTERS and english_words >= EN_READER_MIN_WORDS
+ and not any(term in chinese for term in READER_BANNED_TERMS) and not any(term in english.lower() for term in READER_BANNED_TERMS)
and english_cjk_ratio < 0.05
) else "review_required",
"boundary": "This validates delivery structure and shared evidence coverage. It does not prove a translation is absent or replace technical review.",
}
+def build_single_language_delivery_receipt(
+ dossier: Mapping[str, Any], report: str, language: str,
+) -> dict[str, Any]:
+ """Validate one independently composed customer language without a placeholder peer."""
+ if language not in {"zh", "en"}:
+ raise ValueError("language must be 'zh' or 'en'")
+ headings = ZH_HEADINGS if language == "zh" else EN_HEADINGS
+ text = report if language == "zh" else report.lower()
+ d9_status = _d9_pair_evidence_status(dossier)
+ affection_status = _mutual_affection_status(dossier)
+ section_bodies = _section_bodies(report, headings)
+ section_sizes = {
+ heading: (
+ len(re.findall(r"[\u4e00-\u9fff]", body)) if language == "zh"
+ else len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", body))
+ )
+ for heading, body in section_bodies.items()
+ }
+ min_size = ZH_SECTION_MIN_CHARACTERS if language == "zh" else EN_SECTION_MIN_WORDS
+ opening_heading = "结论总纲" if language == "zh" else "Relationship Thesis"
+ attraction_heading = "吸引与亲密" if language == "zh" else "Attraction and Closeness"
+ opening = _opening_after_heading(report, opening_heading, headings)
+ analyst_terms = ZH_ANALYST_LED_OPENERS if language == "zh" else EN_ANALYST_LED_OPENERS
+ analyst_hits = [term for term in analyst_terms if term in (opening if language == "zh" else opening.lower())]
+ warmth_terms = ZH_AFFECTION_OPENING_TERMS if language == "zh" else EN_AFFECTION_OPENING_TERMS
+ warmth_hits = [term for term in warmth_terms if term in (opening if language == "zh" else opening.lower())]
+ attraction = section_bodies[attraction_heading] if language == "zh" else section_bodies[attraction_heading].lower()
+ attraction_warmth = ZH_AFFECTION_SECTION_WARMTH_TERMS if language == "zh" else EN_AFFECTION_SECTION_WARMTH_TERMS
+ attraction_boundary = ZH_AFFECTION_SECTION_BOUNDARY_TERMS if language == "zh" else EN_AFFECTION_SECTION_BOUNDARY_TERMS
+ attraction_warmth_hits = [term for term in attraction_warmth if term in attraction]
+ attraction_boundary_hits = [term for term in attraction_boundary if term in attraction]
+ narrative_d9_terms = ZH_D9_TERMS if language == "zh" else EN_D9_TERMS
+ d9_present = any(term.lower() in text for term in narrative_d9_terms)
+ reader_depth = len(re.findall(r"[\u4e00-\u9fff]", report)) if language == "zh" else len(re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", report))
+ reader_min = ZH_READER_MIN_CHARACTERS if language == "zh" else EN_READER_MIN_WORDS
+ forbidden = READER_BANNED_TERMS
+ forbidden_hits = [term for term in forbidden if term in (report if language == "zh" else report.lower())]
+ supplementary_hits = [term for term in SUPPLEMENTARY_METHOD_TERMS if term in report]
+ private_hits = [term for term in PRIVATE_EVIDENCE_TERMS if term in report]
+ checks = {
+ "dossier_ready": dossier.get("delivery_readiness") == "ready_for_narrative",
+ "all_headings": all(_heading_status(report, headings).values()),
+ "opening_is_reader_led": not analyst_hits,
+ "opening_affection": affection_status != "executed" or bool(warmth_hits),
+ "section_depth": all(size >= min_size for size in section_sizes.values()),
+ "attraction_reciprocity": affection_status != "executed" or bool(attraction_warmth_hits and attraction_boundary_hits),
+ "d9_coverage": d9_status != "executed" or d9_present,
+ "reader_depth": reader_depth >= reader_min,
+ "no_forbidden_language": not forbidden_hits,
+ "no_supplementary_method_names": not supplementary_hits,
+ "no_private_evidence_terms": not private_hits,
+ "english_is_not_mostly_cjk": language != "en" or len(re.findall(r"[\u4e00-\u9fff]", report)) / max(len(report), 1) < 0.05,
+ }
+ return {
+ "schema": SINGLE_LANGUAGE_SCHEMA,
+ "dossier_schema": dossier.get("schema"),
+ "language": language,
+ "composition": "direct_from_structured_dossier" if language == "zh" else "direct_from_structured_dossier_not_translated_from_chinese",
+ "headings": _heading_status(report, headings),
+ "checks": checks,
+ "source_map_status": _dossier_status(dossier),
+ "d9_narrative_coverage": {"available_status": d9_status, "required_in_narrative": d9_status == "executed", "present": d9_present},
+ "opening_quality": {"analyst_led_openers": analyst_hits, "status": "passed" if not analyst_hits else "review_required"},
+ "opening_affection_coverage": {"available_status": affection_status, "terms": warmth_hits, "status": "passed" if checks["opening_affection"] else "review_required"},
+ "section_depth_coverage": {"sizes": section_sizes, "minimum": min_size, "status": "passed" if checks["section_depth"] else "review_required"},
+ "attraction_reciprocity_coverage": {"warmth_terms": attraction_warmth_hits, "boundary_terms": attraction_boundary_hits, "status": "passed" if checks["attraction_reciprocity"] else "review_required"},
+ "reader_depth": {"value": reader_depth, "minimum": reader_min, "status": "passed" if checks["reader_depth"] else "review_required"},
+ "status": "passed" if all(checks.values()) else "review_required",
+ "boundary": "This validates one independently authored language. It does not certify a future second-language report or treat one language as a translation source.",
+ }
+
+
def main() -> int:
parser = argparse.ArgumentParser(description="Validate independent Chinese and English full synastry reports.")
parser.add_argument("--dossier", required=True, help="Private full-synastry dossier JSON")
- parser.add_argument("--chinese", required=True, help="Chinese Markdown report")
- parser.add_argument("--english", required=True, help="English Markdown report written directly from the dossier")
+ parser.add_argument("--chinese", help="Chinese Markdown report")
+ parser.add_argument("--english", help="English Markdown report written directly from the dossier")
+ parser.add_argument("--language", choices=("zh", "en"), help="Validate one independently authored language")
+ parser.add_argument("--report", help="Single-language Markdown report used with --language")
parser.add_argument("--output", help="Private receipt output path")
args = parser.parse_args()
dossier = json.loads(Path(args.dossier).read_text(encoding="utf-8"))
- chinese = Path(args.chinese).read_text(encoding="utf-8")
- english = Path(args.english).read_text(encoding="utf-8")
- receipt = build_delivery_receipt(dossier, chinese, english)
+ if args.language:
+ if not args.report or args.chinese or args.english:
+ parser.error("--language requires --report and cannot be combined with --chinese or --english")
+ receipt = build_single_language_delivery_receipt(
+ dossier, Path(args.report).read_text(encoding="utf-8"), args.language,
+ )
+ else:
+ if not args.chinese or not args.english:
+ parser.error("bilingual validation requires --chinese and --english")
+ receipt = build_delivery_receipt(
+ dossier,
+ Path(args.chinese).read_text(encoding="utf-8"),
+ Path(args.english).read_text(encoding="utf-8"),
+ )
encoded = json.dumps(receipt, ensure_ascii=False, indent=2)
if args.output:
Path(args.output).write_text(encoded + "\n", encoding="utf-8")
diff --git a/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py b/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py
index c5b5b219..a6aa7b79 100644
--- a/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py
+++ b/skills/jyotish-full-synastry-reading/scripts/validate_full_synastry_packets.py
@@ -66,7 +66,7 @@ REQUIRED_TECHNIQUES: tuple[tuple[str, tuple[str, ...], str], ...] = (
("Ashtottari Dasha", ("ashtottari_dasha",), "conditional secondary timing; verify applicability"),
("Kalachakra Dasha", ("kalachakra_dasha",), "conditional secondary timing; verify method profile"),
("Navamsha Dasha", ("navamsha_dasha",), "D9-oriented secondary timing; parameter-sensitive"),
- ("Niryaana Shoola Dasha", ("niryaana_shoola_dasha",), "specialized secondary timing; do not lead conclusions"),
+ ("Niryaana Shoola Dasha", ("niryaana_shoola_dasha",), "longevity-specific system; not applicable to relationship timing"),
("Shastihayani Dasha", ("shastihayani_dasha",), "conditional secondary timing; applicability required"),
("Dasha Convergence", ("dasa_convergence",), "cross-dasha agreement within each chart"),
("Relationship Strict Evidence", ("relationship_strict_evidence",), "relationship-route rule and book-evidence payload"),
@@ -86,7 +86,7 @@ PAIR_TASKS: tuple[tuple[str, tuple[str, ...], str], ...] = (
),
(
"shared gain and cost: resources, home, work and private-space axes",
- ("chart", "varga_full.D2_Hora", "varga_full.D4_Chaturthamsa", "varga_full.D10_Dasamsa", "bhava_bala", "ashtakavarga"),
+ ("chart", "varga_full.D2_Hora", "varga_full.D4_Turyamsa", "varga_full.D10_Dasamsa", "bhava_bala", "ashtakavarga"),
"compare 2/4/6/10/11/12-house capacity and relevant varga context; no financial prediction from compatibility alone",
),
(
@@ -187,6 +187,13 @@ def _profile(packet: Mapping[str, Any]) -> dict[str, Any]:
def _technique_row(packet: Mapping[str, Any], definition: tuple[str, tuple[str, ...], str]) -> dict[str, Any]:
label, candidates, purpose = definition
+ if label == "Niryaana Shoola Dasha":
+ return {
+ "technique": label,
+ "status": "not_applicable",
+ "source_path": None,
+ "purpose": purpose,
+ }
modules = packet.get("modules", {}) if isinstance(packet.get("modules"), Mapping) else {}
found_path = next((path for path in candidates if _get_path(modules, path) is not None), None)
value = _get_path(modules, found_path) if found_path else None
@@ -253,8 +260,11 @@ def build_execution_receipt(person_a: Mapping[str, Any], person_b: Mapping[str,
rows = []
for definition in REQUIRED_TECHNIQUES:
a_row, b_row = _technique_row(a, definition), _technique_row(b, definition)
- combined = "executed" if a_row["status"] == b_row["status"] == "executed" else "blocked"
- if combined == "blocked" and {a_row["status"], b_row["status"]} <= {"executed", "partial", "parameter_sensitive", "observation_only", "guarded"}:
+ individual_statuses = {a_row["status"], b_row["status"]}
+ combined = "executed" if individual_statuses == {"executed"} else "blocked"
+ if individual_statuses == {"not_applicable"}:
+ combined = "not_applicable"
+ elif combined == "blocked" and individual_statuses <= {"executed", "partial", "parameter_sensitive", "observation_only", "guarded", "not_applicable"}:
combined = "parameter_sensitive"
rows.append({
"technique": definition[0],
diff --git a/tests/test_synastry_full_reader_gate.py b/tests/test_synastry_full_reader_gate.py
new file mode 100644
index 00000000..85567999
--- /dev/null
+++ b/tests/test_synastry_full_reader_gate.py
@@ -0,0 +1,158 @@
+"""Relationship-language gate. The guide's example, padded to the length floor, passes."""
+
+from __future__ import annotations
+
+import copy
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+SCRIPTS = ROOT / "scripts"
+if str(SCRIPTS) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS))
+
+from marriage_match_service.synastry_full_reader_gate import validate_synastry_reader
+
+GUIDE = (
+ "你们这段关系的底色是有吸引,但节奏不同。刚靠近时,彼此容易觉得对方身上有自己缺的东西,所以会有自然的牵引。"
+ "真正进入日常后,差异会浮出来:一个更习惯用行动表达关心,一个更需要语言和情绪回应;压力大的时候,一个想立刻解决,一个想先躲开。"
+ "这不是谁对谁错,而是你们的安全感开关不在同一个位置。你们的优势在于,只要愿意把话说明白,理解力是在的;"
+ "难点在于,情绪上来时容易把我需要空间听成你不在乎我。长期看,这段关系不是靠感觉自动顺滑的类型,"
+ "而是靠两个人把日常回应、冲突修复和现实安排一点点对齐。如果愿意练,它会长出很稳的默契;如果回避差异,摩擦会反复出现在同一个地方。"
+)
+FILLER = "你们可以慢慢把彼此的节奏说清楚,把靠近、停顿和修复都留在两个人都能接受的距离里。"
+
+
+def _prose(seed: str) -> str:
+ text = seed
+ while len(text.replace(" ", "")) < 280:
+ text += FILLER
+ return text
+
+
+def _reason(plain: str, basis: str, evidence: str) -> dict:
+ return {
+ "plain": plain,
+ "basis": basis,
+ "evidence": evidence,
+ "label": "single_system_inference",
+ }
+
+
+def _passing(language: str = "zh") -> tuple[dict, dict]:
+ evidence = "金星落在第七宫,同时木星大运覆盖接下来这一段"
+ inputs = {
+ section: {"quote": evidence, "note": "双方都需要把靠近说出来"}
+ for section in (
+ "climate", "emotion", "communication", "safety", "conflict", "daily", "direction", "practices",
+ )
+ }
+ inputs["timeline"] = {"quote": "程序分好的三段"}
+ reasons = [
+ _reason("所以你们靠近时会先感到彼此补上了一块缺口。", evidence, evidence),
+ _reason("所以压力一大,一个想说开,一个想先留出空间。", evidence, evidence),
+ ]
+ titles = ("关系基调", "情感节奏", "沟通与理解", "安全感与亲密", "冲突与修复", "生活节律与现实协作", "长期方向")
+ sections = []
+ for index, section_id in enumerate(("climate", "emotion", "communication", "safety", "conflict", "daily", "direction")):
+ text = _prose(GUIDE if index == 0 else f"你们在{titles[index]}上需要彼此对齐。{GUIDE}")
+ if language == "en":
+ text = " ".join(["You two meet a different pace and can still name what each person needs."] * 90)
+ sections.append({
+ "id": section_id,
+ "title": titles[index],
+ "text": text,
+ "reasons": reasons if language == "zh" else [
+ _reason("So closeness asks both of you to say what care looks like.", "Venus in the 7th house during Jupiter dasha", evidence),
+ _reason("So pressure sends one person toward talk and the other toward room.", "Venus in the 7th house during Jupiter dasha", evidence),
+ ],
+ })
+ if language == "en":
+ evidence = "Venus in the 7th house during Jupiter dasha"
+ for section_id, payload in inputs.items():
+ if section_id == "timeline":
+ continue
+ payload["quote"] = evidence
+ for section in sections:
+ if section["id"] == "timeline":
+ continue
+ for reason in section["reasons"]:
+ reason["evidence"] = evidence
+ reason["basis"] = evidence
+ sections.append({
+ "id": "timeline",
+ "segments": [
+ {"id": "s1", "phase": "靠近", "text": _prose("这段时间你们适合慢慢认识,把节奏说清楚。") if language == "zh" else "This stretch is for learning each other's pace. " * 20, "basis": evidence},
+ {"id": "s2", "phase": "对齐", "text": _prose("这段时间适合把日常安排摊开谈。") if language == "zh" else "This stretch is for naming daily arrangements. " * 20, "basis": evidence},
+ {"id": "s3", "phase": "稳住", "text": _prose("这段时间关系看你们是否把差异留在桌上。") if language == "zh" else "This stretch shows whether differences stay discussable. " * 20, "basis": evidence},
+ ],
+ })
+ practice = "挑一件日常回应,这周各自说一次自己需要的靠近方式。" if language == "zh" else "Pick one daily response and each say what closeness you need."
+ sections.append({
+ "id": "practices",
+ "items": [{"text": practice, "evidence": evidence if language == "en" else "金星落在第七宫,同时木星大运覆盖接下来这一段"}],
+ })
+ return {"sections": sections}, inputs
+
+
+def test_guide_example_passes_when_length_and_reasons_are_filled():
+ delivery, inputs = _passing("zh")
+ result = validate_synastry_reader(delivery, inputs, "zh")
+ assert result["ok"], result["failures"]
+
+
+def test_english_guide_shape_passes():
+ delivery, inputs = _passing("en")
+ result = validate_synastry_reader(delivery, inputs, "en")
+ assert result["ok"], result["failures"]
+
+
+def _one(delivery: dict, inputs: dict, language: str = "zh") -> set[str]:
+ result = validate_synastry_reader(delivery, inputs, language)
+ assert result["ok"] is False
+ return {row["code"] for row in result["failures"]}
+
+
+def test_basis_house_that_is_not_in_the_evidence_fails():
+ delivery, inputs = _passing("zh")
+ delivery["sections"][0]["reasons"][0]["basis"] = "金星落在第七宫,改成第八宫"
+ assert "basis_mismatch" in _one(delivery, inputs)
+
+
+def test_each_banned_insertion_fails_once():
+ samples = {
+ "金星": "banned_term",
+ "七宫": "banned_term",
+ "22 分": "score",
+ "注定": "banned_judgement",
+ "2027 年结婚": "dated_event",
+ "宿曜": "supplementary_method",
+ "execution_receipt": "private_term",
+ }
+ for snippet, code in samples.items():
+ delivery, inputs = _passing("zh")
+ delivery["sections"][1]["text"] += snippet
+ assert code in _one(delivery, inputs), snippet
+
+
+def test_analytic_opening_missing_section_and_missing_claim_fail():
+ delivery, inputs = _passing("zh")
+ delivery["sections"][0]["text"] = "这段关系的核心不是顺滑。" + delivery["sections"][0]["text"]
+ assert "analytic_opening" in _one(delivery, inputs)
+
+ delivery, inputs = _passing("zh")
+ delivery["sections"] = [row for row in delivery["sections"] if row["id"] != "safety"]
+ assert "section_order" in _one(delivery, inputs)
+
+ delivery, inputs = _passing("zh")
+ delivery["sections"][2]["reasons"] = delivery["sections"][2]["reasons"][:1]
+ assert "reason_count" in _one(delivery, inputs)
+
+
+def test_english_banned_word_fails():
+ delivery, inputs = _passing("en")
+ delivery["sections"][1]["text"] += " Venus "
+ assert "banned_term" in _one(delivery, inputs, "en")
+ delivery, inputs = _passing("en")
+ delivery["sections"][0]["text"] = "The core of this relationship is not ease. " + delivery["sections"][0]["text"]
+ assert "analytic_opening" in _one(delivery, inputs, "en")