From 9ea5b3b0c072c6b2491e4067fb2316003cbe74e6 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 19 Oct 2026 10:03:55 +0800 Subject: [PATCH] feat(synastry): write the full relationship report on demand The pair page and reader replace the leftover chat draft, and the same pair is not charged again. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- CHANGELOG.md | 7 + docs/BUG_HISTORY.md | 20 +- .../PROGRESS-synastry-full-report-20261018.md | 36 +++ docs/testing/synastry-full-report-20261018.md | 18 ++ frontend/DESIGN.md | 4 + frontend/docs/VOICE.md | 13 + frontend/src/app/(app)/synastry/page.tsx | 5 + .../app/api/admin/feature-pricing/route.ts | 1 + frontend/src/app/api/admin/products/route.ts | 2 +- .../app/api/admin/usage/aggregate/route.ts | 1 + .../src/app/api/chart-profiles/[id]/route.ts | 5 +- .../api/reports/[reportId]/english/route.ts | 60 ++++ .../src/app/api/reports/[reportId]/route.ts | 23 +- frontend/src/app/api/reports/route.ts | 19 +- frontend/src/app/globals.css | 17 + .../components/admin/pricing-simulator.tsx | 2 +- frontend/src/components/app-sidebar.tsx | 2 + frontend/src/components/people-home-links.tsx | 21 +- .../src/components/people/people-page.tsx | 6 +- .../personal-report-center.tsx | 2 +- .../personal-report/personal-report-page.tsx | 21 +- .../components/synastry/synastry-picker.tsx | 138 +++++++++ .../synastry/synastry-report-view.tsx | 156 ++++++++++ .../src/lib/admin/feature-pricing-labels.ts | 1 + frontend/src/lib/consultation-billing.ts | 3 +- frontend/src/lib/marriage-match-service.ts | 4 + frontend/src/lib/membership.ts | 1 + .../src/lib/personal-report-service-core.ts | 37 ++- .../src/lib/personal-report-worker-core.ts | 57 +++- frontend/src/lib/personal-report-worker.ts | 22 +- frontend/src/lib/synastry-full-create.ts | 263 ++++++++++++++++ frontend/src/lib/synastry-full-generate.ts | 221 +++++++++++++ frontend/src/lib/synastry-full-run.ts | 277 +++++++++++++++++ frontend/src/lib/synastry-full-sections.ts | 130 ++++++++ frontend/src/lib/synastry-full-technical.ts | 92 ++++++ frontend/src/lib/synastry-full-timeline.ts | 163 ++++++++++ frontend/src/mastra/synastry-report.ts | 70 +++++ .../20261019010000_synastry_full_report.sql | 260 ++++++++++++++++ .../tests/database-local-business.test.ts | 4 + .../tests/feature-pricing-contract.test.ts | 1 + frontend/tests/synastry-full-report.test.ts | 135 ++++++++ scripts/jyotish_api_server.py | 7 + scripts/marriage_match_service/api.py | 16 + .../marriage_match_service/full_dossier.py | 60 ++++ .../synastry_full_reader_gate.py | 290 ++++++++++++++++++ skills/jyotish-full-synastry-reading/SKILL.md | 27 +- .../agents/openai.yaml | 2 +- .../scripts/compose_full_synastry_delivery.py | 68 +++- .../scripts/prepare_full_synastry_packets.py | 2 +- .../validate_bilingual_synastry_delivery.py | 146 ++++++--- .../scripts/validate_full_synastry_packets.py | 18 +- tests/test_synastry_full_reader_gate.py | 158 ++++++++++ 52 files changed, 2987 insertions(+), 127 deletions(-) create mode 100644 docs/tasks/PROGRESS-synastry-full-report-20261018.md create mode 100644 docs/testing/synastry-full-report-20261018.md create mode 100644 frontend/src/app/(app)/synastry/page.tsx create mode 100644 frontend/src/app/api/reports/[reportId]/english/route.ts create mode 100644 frontend/src/components/synastry/synastry-picker.tsx create mode 100644 frontend/src/components/synastry/synastry-report-view.tsx create mode 100644 frontend/src/lib/synastry-full-create.ts create mode 100644 frontend/src/lib/synastry-full-generate.ts create mode 100644 frontend/src/lib/synastry-full-run.ts create mode 100644 frontend/src/lib/synastry-full-sections.ts create mode 100644 frontend/src/lib/synastry-full-technical.ts create mode 100644 frontend/src/lib/synastry-full-timeline.ts create mode 100644 frontend/src/mastra/synastry-report.ts create mode 100644 frontend/supabase/migrations/20261019010000_synastry_full_report.sql create mode 100644 frontend/tests/synastry-full-report.test.ts create mode 100644 scripts/marriage_match_service/synastry_full_reader_gate.py create mode 100644 tests/test_synastry_full_reader_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f4fe94b8..5811cb8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-19 — 完整合盘报告 + +- 侧栏新增「婚恋合盘」。选两个人后生成一份九节关系画像,分析逻辑默认收着。同一对、出生资料和规则版本没变时,再次打开不扣点。 +- 中文先写好。第一次切到英文时按同一份证据另写一版,不再扣点。写不出来时页面写「英文版即将提供」。 +- 计费键是 `synastry.full`,价格跟其他功能一样从配置里读,没有写死。档案页「和我合盘」和旧的 `/?synastry=` 都进选人页,不再往对话里塞草稿(BUG-1373)。 +- Skill 正文与上游 `7a773d13` 的六份文件对齐。Skill 版本不变。 + ## 2026-10-18 — 完整解读补上西占对照和五处缺失模块 - 个人报告里的西占对照层不再因为缺文件而整段空白。它仍只作对照,不拿来改印度占星的结论。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 68891215..43552033 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36540,19 +36540,19 @@ ## BUG-1373 | 合盘结果卡被删掉后只剩一句聊天草稿 -- 状态:investigating +- 状态:resolved - 首次发现:2026-10-17 -- 最近更新:2026-10-18 -- 影响面:合盘页面 +- 最近更新:2026-10-19 +- 影响面:合盘入口 - 用户现象:做完合盘看不到结果卡,输入框里只出现一句草稿问题。 -- 触发条件:从档案页点旧的合盘入口。 -- 根因:结果卡随旧面板一起删除,没有新的结果页接上。 -- 修复:本单不改页面。由后续 issue #82 做结果页并改为 resolved。 -- 验证:无。本单没有页面。 -- 防复发:无。留给 #82。 -- 相关记录:BUG-1370。 +- 触发条件:从档案页点旧的合盘入口,或打开 `/?synastry=`。 +- 根因:结果卡随旧面板一起删除,入口仍往对话里塞一句草稿。 +- 修复:档案页「和我合盘」以及 `/?synastry=` 都进 `/synastry?with=`,不再写聊天草稿。完整报告在报告阅读页展开。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 核对入口不再指向对话草稿。 +- 防复发:入口只去合盘选人页。 +- 相关记录:BUG-1370。issue #82。 - 复发自:无。 -- 修复版本:未修。 +- 修复版本:分支 `codex/synastry-full-report-20261018`。 ## BUG-1374 | 文档把 Papasamya 和 Mangal 匹配写成已经实现 diff --git a/docs/tasks/PROGRESS-synastry-full-report-20261018.md b/docs/tasks/PROGRESS-synastry-full-report-20261018.md new file mode 100644 index 00000000..cd197d40 --- /dev/null +++ b/docs/tasks/PROGRESS-synastry-full-report-20261018.md @@ -0,0 +1,36 @@ +# 进度:完整合盘报告(2026-10-19) + +分支 `codex/synastry-full-report-20261018`。基线 `2130682d4`。对应 issue #82,任务书 `docs/tasks/TASK-synastry-full-report-20261018.md`。 + +## 做了什么 + +- 报告类型 `synastry_full` 落在现有报告表上。两人用 `subject_low` / `subject_high` 和各自出生指纹保存。同一对、指纹和规则版本都没变的已完成报告直接打开,不再扣点。 +- 计费键 `synastry.full`。价格仍从配置读取。没配价格时与个人报告一样返回「计费配置不可用」。结算事件键是 `synastry.full`,用量记录 `kind=synastry_full`。 +- 正文九节。第 1–7 节先给结论,分析逻辑默认收起。时间线由程序切成 3–5 段,模型只写阶段名和描述。技术依据六块由程序生成,浏览器拿不到证据包。 +- 质检在 `scripts/marriage_match_service/synastry_full_reader_gate.py`,接口 `/api/synastry_full_reader_gate`。没有放在 `scripts/` 根目录,因为根目录 Python 文件数预算是 448。 +- 中文先生成。英文另有一张 `synastry_english_jobs`,主队列空闲时才写,不再扣点。两次都没过质检就失败并释放预扣。 +- 侧栏「婚恋合盘」、选人页、报告阅读页。档案「和我合盘」和 `/?synastry=` 都进 `/synastry?with=`(BUG-1373)。 +- 上游 `7a773d13` 快照里的六份技能文件已原样覆盖到 `skills/jyotish-full-synastry-reading/`。 + +## 和任务书不一致的地方 + +- 质检文件不在 `scripts/synastry_full_reader_gate.py`。根目录再加一个 `.py` 会顶破文件数预算。模块路径是 `marriage_match_service.synastry_full_reader_gate`。 +- 「可以一起聊聊」的四句任务书没有逐字给出,按关系语言指南里的摩擦主题写了四句,并记在 `frontend/docs/VOICE.md`。 +- 「对哪一段想再问清楚?」按任务书先不显示。 +- `aspects.py` 不在这批上游快照里,本分支没有改它。合并前如果任务书仍要求那次同步,需要单独核对。 +- 各自婚恋结构这一块目前写出上升、金星、月亮。证据包里没有单独给出的七宫主、DK、UL、A7、D9,没有编造。 + +## 自测 + +- `tsc --noEmit`:通过。 +- 改动文件的 `eslint`:0 error。 +- `frontend/tests/synastry-full-report.test.ts` 与计费标签合同:通过。 +- `tests/test_synastry_full_reader_gate.py`:6 通过。 +- `npm run test:db`:迁移能建出 `synastry_english_jobs`。业务表清单断言已按三栏改过,单文件重跑通过。其余 13 个失败是备份权限、环境文件校验,以及 Windows 上 `G:\G:\` 路径把迁移脚本找丢,和这次表结构无关。 +- 浏览器:本机没有对着登录后的页面点选。选人、等待、揭幕留给 `docs/testing/synastry-full-report-20261018.md`。 +- 真实模型:测试注入了写作者,没有调用线上模型。 + +## 环境缺口 + +- 英文写不出来时页面显示「英文版即将提供」,没有机器翻译。 +- 外部资料与真实案例核对固定写成「未进行」。 diff --git a/docs/testing/synastry-full-report-20261018.md b/docs/testing/synastry-full-report-20261018.md new file mode 100644 index 00000000..e2e5c662 --- /dev/null +++ b/docs/testing/synastry-full-report-20261018.md @@ -0,0 +1,18 @@ +# 真人验收:完整合盘报告 + +登录后做。不要把出生日期、地点或报告正文抄进反馈,只写看到了什么。 + +1. 侧栏有「婚恋合盘」。打开后能选两个人。人多时下拉可以滚动。 +2. 「+ 新增星盘」能保存一个新人,并出现在列表里。 +3. 选同一个人,按钮旁说明要选两个不同的人,不会开始生成。 +4. 资料不齐的人不能生成,页面说明要先补出生资料。 +5. 生成时是现在的报告等待页。正文出现之后不再转圈。 +6. 九节都在。第 1–7 节先是结论,「分析逻辑」默认收着。点开能看到「盘面」和「所以」。 +7. 时间线 3–5 段,每段也能收起分析逻辑。没有「某年结婚」这类句子。 +8. 页尾有四句「可以一起聊聊」,没有「对哪一段想再问清楚?」。 +9. 「技术依据摘要」默认收着。展开后有六块,最后一行是「外部资料与真实案例核对:未进行,本报告是计算层归纳」。页面上看不到整包原始证据。 +10. 同一对人再点一次生成,直接打开原来的报告,点数不变。 +11. 第一次点「看英文版」会先等待,然后整页换成英文。等待期间不先露出半篇。失败时只看到「英文版即将提供」。 +12. 档案页「和我合盘」进合盘选人页,对话输入框里不出现草稿。 +13. 删除其中一个人时,确认句里能看到合盘份数。删掉之后,这份合盘报告也没了。 +14. 价格还没发布时,生成不会扣点,提示与个人报告未定价时相同。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b13f2cc1..5d37b4eb 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1,5 +1,9 @@ # Jyotisha Web Design System +## 婚恋合盘报告(2026-10-19) + +选人页下拉最多约二十人,列表本身可滚动。正文与技术依据在窄屏上换行,不把页面撑出横向滚动。九节里的「分析逻辑」默认收起。英文准备期间沿用报告页的等待区,揭幕后不再出现加载指示。 + ## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途) 首页不再有「生时校正」pill,`.starter-entries` 只剩今日星语一枚(`.starter-entry-hint` 那句「生时校正暂时只支持本人。」随之删除)。入口在对话里: diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 04493704..edfadd88 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -23,6 +23,19 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。 +## 婚恋合盘报告(2026-10-19) + +说「你们之间」,不说「你是什么人 / 他是什么人」。说倾向、需求和条件,不说注定、不合、适合结婚。不写某年结婚、领证或分开。页脚保留一句:合盘描述的是互动的质地,走不走、怎么走,仍然是你们两个人的选择。 + +「可以一起聊聊」用这四句,不再加第五句入口: + +- 靠近的时候,怎样的回应会让你觉得被放在心上? +- 误会出现时,你们更需要先说开,还是先各自安静一会儿? +- 吵完之后,怎样递出台阶才不会变成认输? +- 过日子的安排里,哪一件需要现在就说清楚? + +「对哪一段想再问清楚?」先不显示。英文还没写好时只说「英文版即将提供」。 + ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) - **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 diff --git a/frontend/src/app/(app)/synastry/page.tsx b/frontend/src/app/(app)/synastry/page.tsx new file mode 100644 index 00000000..7b683e2f --- /dev/null +++ b/frontend/src/app/(app)/synastry/page.tsx @@ -0,0 +1,5 @@ +import { SynastryPicker } from "@/components/synastry/synastry-picker"; + +export default function SynastryPage() { + return ; +} diff --git a/frontend/src/app/api/admin/feature-pricing/route.ts b/frontend/src/app/api/admin/feature-pricing/route.ts index bdef2adc..21156d79 100644 --- a/frontend/src/app/api/admin/feature-pricing/route.ts +++ b/frontend/src/app/api/admin/feature-pricing/route.ts @@ -12,6 +12,7 @@ const featureKey = z.enum([ "chat.premium", "rectification", "report.full", + "synastry.full", "report.export", "profile.extra", ]); diff --git a/frontend/src/app/api/admin/products/route.ts b/frontend/src/app/api/admin/products/route.ts index b8410ea0..de06cb25 100644 --- a/frontend/src/app/api/admin/products/route.ts +++ b/frontend/src/app/api/admin/products/route.ts @@ -8,7 +8,7 @@ import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, re export const runtime = "nodejs"; const entitlementSchema = z.object({ - featureKey: z.enum(["chat.standard", "chat.premium", "rectification", "report.full", "report.export", "profile.extra"]), + featureKey: z.enum(["chat.standard", "chat.premium", "rectification", "report.full", "report.export", "profile.extra", "synastry.full"]), allowanceType: z.enum(["access", "unlimited", "quota", "credits"]), allowanceCount: z.number().int().positive().nullable(), resetPeriod: z.enum(["none", "day", "month", "billing_period"]), diff --git a/frontend/src/app/api/admin/usage/aggregate/route.ts b/frontend/src/app/api/admin/usage/aggregate/route.ts index 72c1e704..80c8ea2d 100644 --- a/frontend/src/app/api/admin/usage/aggregate/route.ts +++ b/frontend/src/app/api/admin/usage/aggregate/route.ts @@ -11,6 +11,7 @@ const FEATURE_KEYS = [ "chat.premium", "rectification", "report.full", + "synastry.full", "report.export", "profile.extra", ] as const; diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts index 22e3466f..f20260bf 100644 --- a/frontend/src/app/api/chart-profiles/[id]/route.ts +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -55,14 +55,15 @@ export async function GET(_request: Request, context: RouteContext) { if (!data) return NextResponse.json({ error: "星盘不存在或无权查看" }, { status: 404 }); const usage = await supabase.rpc("chart_subject_usage", { p_id: id }); if (usage.error) throw usage.error; - const row = Array.isArray(usage.data) ? usage.data[0] as { session_count?: number; report_count?: number } : null; + const row = Array.isArray(usage.data) ? usage.data[0] as { session_count?: number; report_count?: number; synastry_count?: number } : null; if (!row || !Number.isInteger(row.session_count) || Number(row.session_count) < 0 || !Number.isInteger(row.report_count) || Number(row.report_count) < 0) { return NextResponse.json({ error: "暂时查不到这个人的对话数量,稍后再试" }, { status: 503 }); } + const synastry = Number.isInteger(row.synastry_count) && Number(row.synastry_count) >= 0 ? row.synastry_count : 0; return NextResponse.json({ profile: toClientProfile(data as Record), - usage: { sessions: row.session_count, reports: row.report_count }, + usage: { sessions: row.session_count, reports: row.report_count, synastry }, }); } catch (error) { if (isSupabaseConfigurationError(error)) { diff --git a/frontend/src/app/api/reports/[reportId]/english/route.ts b/frontend/src/app/api/reports/[reportId]/english/route.ts new file mode 100644 index 00000000..cf1f713a --- /dev/null +++ b/frontend/src/app/api/reports/[reportId]/english/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; + +import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; +import { createSupabasePersonalReportService } from "@/lib/personal-report-service"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { publicSynastryView, SNAPSHOT_SECTION } from "@/lib/synastry-full-run"; + +export const runtime = "nodejs"; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST(_request: Request, context: { params: Promise<{ reportId: string }> }) { + try { + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json({ error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, { status: 404 }); + } + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId); + if (!report || report.reportType !== "synastry_full" || report.status !== "ready") { + return NextResponse.json({ error: "报告不存在", code: REPORT_STABLE_CODES.notFound }, { status: 404 }); + } + const admin = createAdminSupabaseClient(); + const stored = await admin.from("personal_report_sections") + .select("payload") + .eq("user_id", user.id) + .eq("request_id", report.requestId) + .eq("section_id", SNAPSHOT_SECTION) + .maybeSingle(); + if (publicSynastryView(stored.data?.payload)?.english) { + return NextResponse.json({ status: "ready" }); + } + const existing = await admin.from("synastry_english_jobs").select("status").eq("report_id", reportId).maybeSingle(); + if (!existing.data) { + const inserted = await admin.from("synastry_english_jobs").insert({ + report_id: reportId, + user_id: user.id, + status: "queued", + }); + if (inserted.error) throw inserted.error; + } else if (existing.data.status === "failed") { + const reset = await admin.from("synastry_english_jobs").update({ + status: "queued", + last_error_code: null, + updated_at: new Date().toISOString(), + }).eq("report_id", reportId).eq("status", "failed"); + if (reset.error) throw reset.error; + } + return NextResponse.json({ status: "queued" }, { status: 202 }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "英文版暂时无法准备", code: REPORT_STABLE_CODES.generationFailed }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index 8fcda635..7b1c110f 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; import { resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement"; import { + reportView, resolveReportDelete, resolveReportRead, } from "@/lib/personal-report-route-core"; @@ -20,6 +21,7 @@ import { createSupabasePersonalReportJobService } from "@/lib/personal-report-jo import { createPersonalReportSectionService } from "@/lib/personal-report-section-service"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { publicSynastryView, SNAPSHOT_SECTION } from "@/lib/synastry-full-run"; export const runtime = "nodejs"; @@ -47,6 +49,7 @@ async function resolvePersistenceForUser() { if (authError || !user) { return { userId: null as string | null, + supabase: null, persistence: null as PersonalReportService | null, jobs: null, listSections: undefined, @@ -58,6 +61,7 @@ async function resolvePersistenceForUser() { const sections = createPersonalReportSectionService(supabase as never); return { userId: user.id, + supabase, persistence, jobs: createSupabasePersonalReportJobService(supabase), // sectionId and attemptCount are consumed by resolveReportRead to derive @@ -101,7 +105,7 @@ async function resolvePersistenceForUser() { export async function GET(request: Request, context: RouteContext) { try { - const { userId, persistence, jobs, listSections, loadLongformAppendix, loadLongformEnglish } = await resolvePersistenceForUser(); + const { userId, persistence, jobs, listSections, loadLongformAppendix, loadLongformEnglish, supabase } = await resolvePersistenceForUser(); const { reportId } = await context.params; if (!uuidPattern.test(reportId)) { return NextResponse.json( @@ -109,6 +113,23 @@ export async function GET(request: Request, context: RouteContext) { { status: 404 }, ); } + if (userId && persistence && supabase) { + const owned = await persistence.getOwnedById(userId, reportId); + if (owned?.reportType === "synastry_full" && owned.status === "ready") { + const stored = await supabase + .from("personal_report_sections") + .select("payload") + .eq("user_id", userId) + .eq("request_id", owned.requestId) + .eq("section_id", SNAPSHOT_SECTION) + .maybeSingle(); + const synastry = publicSynastryView(stored.data?.payload); + if (!synastry) { + return NextResponse.json({ error: "报告暂时无法读取", code: REPORT_STABLE_CODES.generationFailed }, { status: 500 }); + } + return NextResponse.json({ report: reportView(owned), synastry }); + } + } const response = await resolveReportRead({ requestUrl: request.url, origin: request.headers.get("origin"), diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index f3473d58..8a0e0e82 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -46,6 +46,7 @@ import { } from "@/lib/subject-birth"; import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; import { clampReportLimit, nextReportCursor, parseReportCursor, reportCursorFilter } from "@/lib/report-cursor"; +import { createSynastryFullReport } from "@/lib/synastry-full-create"; export const runtime = "nodejs"; export const maxDuration = 120; @@ -118,17 +119,20 @@ export async function GET(request: Request) { if (beforeRaw && !cursor) { return NextResponse.json({ error: "报告列表请求无效" }, { status: 400 }); } + const reportType = url.searchParams.get("reportType"); const subjectId = subjectIdFromQuery(url.searchParams.get("subject")); - if (subjectId !== "self" && !isChartSubjectId(subjectId)) { + if (reportType !== "synastry_full" && subjectId !== "self" && !isChartSubjectId(subjectId)) { return NextResponse.json({ error: "星盘不存在", code: "subject_not_found" }, { status: 404 }); } let listQuery = supabase .from("personal_reports") .select(REPORT_LIST_COLUMNS) .eq("user_id", user.id); - listQuery = subjectId === "self" - ? listQuery.is("chart_profile_id", null) - : listQuery.eq("chart_profile_id", subjectId); + listQuery = reportType === "synastry_full" + ? listQuery.eq("report_type", "synastry_full") + : subjectId === "self" + ? listQuery.is("chart_profile_id", null) + : listQuery.eq("chart_profile_id", subjectId); listQuery = listQuery .order("created_at", { ascending: false }) .order("id", { ascending: false }) @@ -206,7 +210,12 @@ export async function POST(request: Request) { const persistence: PersonalReportService = createSupabasePersonalReportService(admin); const adminDataClient = createPersonalReportDataClient(admin); - const normalized = normalizeReportSubjectRequest(await request.json().catch(() => null)); + const rawJson = await request.json().catch(() => null); + if (rawJson && typeof rawJson === "object" && (rawJson as { reportType?: unknown }).reportType === "synastry_full") { + const created = await createSynastryFullReport({ userId, body: rawJson, supabase, admin }); + return toNextResponse(created); + } + const normalized = normalizeReportSubjectRequest(rawJson); if (normalized.mismatch) { return NextResponse.json({ error: "报告人物不一致", code: "subject_mismatch" }, { status: 400 }); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5c4a0c23..a62dbaeb 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4520,6 +4520,23 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class line-height: 1.45; } +.synastry-report, +.synastry-picker { + width: 100%; + max-width: 100%; + overflow-wrap: anywhere; + padding: var(--space-6); +} + +.synastry-picker-list { + max-height: 16rem; + overflow-y: auto; +} + +.synastry-report details { + margin-top: var(--space-3); +} + .personal-report-state { width: 100%; height: 100%; diff --git a/frontend/src/components/admin/pricing-simulator.tsx b/frontend/src/components/admin/pricing-simulator.tsx index 34e9596f..a81eabc1 100644 --- a/frontend/src/components/admin/pricing-simulator.tsx +++ b/frontend/src/components/admin/pricing-simulator.tsx @@ -16,7 +16,7 @@ import { type UsageAggregate, } from "@/lib/pricing-simulation"; -const FEATURE_KEYS = ["chat.standard", "chat.premium", "rectification", "report.full"] as const; +const FEATURE_KEYS = ["chat.standard", "chat.premium", "rectification", "report.full", "synastry.full"] as const; const BANDS: DistributionBand[] = [ { weight: 0, monthlyMessages: 0 }, { weight: 0, monthlyMessages: 0 }, diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 4dc40275..119ac71d 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { CalendarDays, ChevronRight, FileText, + Heart, LogOut, Orbit, Settings, @@ -107,6 +108,7 @@ const NAV_PAGES = [ { href: "/chart", label: "星盘", icon: Orbit }, { href: "/ephemeris", label: "星历", icon: CalendarDays }, { href: "/reports", label: "我的报告", icon: FileText }, + { href: "/synastry", label: "婚恋合盘", icon: Heart }, { href: "/people", label: "星盘档案", icon: Users }, ] as const; diff --git a/frontend/src/components/people-home-links.tsx b/frontend/src/components/people-home-links.tsx index 5ad1f541..f418c39c 100644 --- a/frontend/src/components/people-home-links.tsx +++ b/frontend/src/components/people-home-links.tsx @@ -25,24 +25,11 @@ export function SynastryDeepLink(props: { useEffect(() => { if (started.current) return; const id = new URLSearchParams(window.location.search).get("synastry"); - if (!id || !props.profile.name.trim() || !props.profile.date) return; + if (!id) return; started.current = true; - stripQuery("synastry"); - void (async () => { - const response = await fetch(`/api/chart-profiles/${encodeURIComponent(id)}`, { cache: "no-store" }); - if (!response.ok) return; - const payload = await response.json() as { profile?: { id?: string; profile?: Profile } }; - const row = payload.profile; - if (!row?.id || !row.profile) return; - await props.draft({ - id: row.id, - role: "other", - profile: row.profile, - relationship: "other", - updatedAt: Date.now(), - }, "romance"); - })(); - }, [props.draft, props.profile]); + window.location.replace(`/synastry?with=${encodeURIComponent(id)}`); + }, []); + void props; return null; } diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index 6a5cdf67..1d6e00c9 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -39,7 +39,7 @@ export type PersonRow = { readonly incomplete: boolean; }; -export type Usage = { id: string; sessions: number; reports: number }; +export type Usage = { id: string; sessions: number; reports: number; synastry?: number }; const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试"; /** `?person=new` marks the add form; a reload or Forward onto it shows the list. */ const NEW_PERSON_PARAM = "new"; @@ -488,7 +488,7 @@ export function PeopleArchiveView({ {selected.role === "other" ? ( - + ) : null} {/* BUG-1081: 本人 is edited here too; only deleting stays off. */}
@@ -503,7 +503,7 @@ export function PeopleArchiveView({ {pendingDelete?.id === selected.id && selected.role === "other" ? (
-

将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。

+

将同时删除 {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) => ( +
  1. +

    盘面:{reason.basis}

    +

    所以:{reason.plain}

    +
  2. + ))} +
+
+
+ ); + })} +
+

{SECTION_TITLES.timeline[language]}

+
    + {(body.timeline ?? []).map((segment) => ( +
  1. +

    {segment.phase}

    +

    {segment.text}

    +
    + 分析逻辑 +

    盘面:{segment.basis}

    +
    +
  2. + ))} +
+
+
+

{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")