diff --git a/CHANGELOG.md b/CHANGELOG.md index 1638349a..7074aab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-19 — 完整合盘报告第二次打回后的修复 + +- 技术依据里两个人的婚恋结构改从完整星盘读取,不再整块显示「未标明」。 +- 侧栏的「婚恋合盘」默认先不出现,确认服务开着才显示。侧栏自己不再去问后台。 +- 同一对已完成的合盘仍然不能重复占一条;生成中删掉报告会退点。Skill 版本不变(BUG-1394)。 + ## 2026-10-19 — 完整合盘报告打回后的修复 - 关系画像每一节都改用证据包里对应的那一节,不再拿空对象去写。英文时间线的依据由程序生成。某一节写失败只重试这一节。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d91815d0..f0586d2f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36856,3 +36856,19 @@ - 相关记录:BUG-1373。issue #82。 - 复发自:无。 - 修复版本:分支 `codex/synastry-full-report-20261018`。 + +## BUG-1394 | 合盘技术依据全是未标明,侧栏自己去请求开关 + +- 状态:resolved +- 首次发现:2026-10-19 +- 最近更新:2026-10-19 +- 影响面:婚恋合盘报告、侧栏 +- 用户现象:技术依据第二块在真实报告上每一项都是「未标明」。侧栏一挂上就去请求产品开关,和其他页面的预取清单对不上。 +- 触发条件:生成完整合盘报告后展开技术依据;或任意页面挂上侧栏。验收打回见 issue #82 第二份修复单。 +- 根因:上游快照只给上升和行星经度。结构行却去读星座、7 宫主、DK、UL、A7、D9,这些字段不在快照里。侧栏为了藏「婚恋合盘」自己发了 `GET /api/product-access`,合同要求它只吃上层传来的数据。 +- 修复:胶水层从两人的完整星盘补出这些结构字段,放进证据包的 `local_structures`。侧栏改为接收 `compatibilityEnabled`,默认不显示,由外壳在开关确认为开之后再传入。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 用公开名人证据包核对第 ② 块不是整行未标明,并逐行核对第 ⑥ 块。`frontend/tests/sidebar-contract.test.ts` 核对侧栏源码不含 `fetch(`。`frontend/tests/database-synastry-full-report.test.ts` 核对同一对复用、指纹变化、删人级联,以及预留、结算、释放和生成中删除退点。 +- 防复发:不要从经度快照里读它没有的星座字段。不要在侧栏里发请求。 +- 相关记录:BUG-1393。issue #82。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-full-report-20261018`。 diff --git a/docs/tasks/PROGRESS-synastry-full-report-20261018.md b/docs/tasks/PROGRESS-synastry-full-report-20261018.md index 955e134d..e4655377 100644 --- a/docs/tasks/PROGRESS-synastry-full-report-20261018.md +++ b/docs/tasks/PROGRESS-synastry-full-report-20261018.md @@ -49,3 +49,18 @@ - 选人页按钮改为「开始合盘」,资料不全的人不可选,并显示点数和「之前的合盘」。阅读页标题带「·」。等待文案能区分排盘、写作和核对。两人都是他人时,两个人的报告列表都能看到这份合盘。产品开关关掉时,侧栏、选人页和英文路由都不开放。 - `aspects.py` 仍用仓库里已有的那份,没有按快照另写一份。上游测试里 270° 的例子改成 240°,因为现有实现会先把超过 180° 的角折回。三栏写在 `tests/test_full_synastry_dossier.py`。 - 本机环境变量里没有模型 key。没有跑真实模型,不能写成质检通过。见 `BLOCKED.md`。 + +## 2026-10-19 第二份修复单 + +对照 `docs/tasks/TASK-synastry-full-report-fix-20261019-2.md`。产品在 issue #82 选了 A。`origin/staging` 再次合并进本分支,没有 rebase。 + +- 补了数据库测试:同一对指纹不能有两份已完成报告,指纹一变就可以再有一份,删掉其中一个人后合盘报告一起消失。`synastry.full` 的预留、结算、释放,以及生成中删除后退点,都在这份测试里。 +- 补了建单路由测试:同一人 400,别人的 id 在扣点前拒绝,资料没变时直接复用且不预留,产品开关关闭返回 503。英文路由在开关关闭时同样 503。质检不过的合盘在 worker 里会退点。 +- 技术依据第 ⑥ 块改用公开名人证据包里的 `execution_receipt` 逐行比对。第 ② 块从两人完整星盘补了上升、金星、月亮、7 宫主、DK、UL、A7、D9,放在证据包的 `local_structures`,不再整块都是「未标明」。 +- 测试里不再写本机 Python 路径。没有 `PYTHON`、`python3`、`python` 时跳过并打印原因。 +- 上游 270° 用例改回原值,并标了 strict xfail。`aspects.py` 仍然不改。预检路径先换成正斜杠再比较。 +- 英文任务认领之后的步骤都在 try 里。空闲任务的异常会记一条日志,不再变成没人接的拒绝。租约从 10 分钟改为单份英文生成上限的 2 倍(九节、每次最多两遍、三节并行、每节 90 秒,上限 9 分钟,租约 18 分钟)。 +- 侧栏不再自己请求。合盘入口默认先藏,外壳拿到开关为开才显示。未登录也可以读开关和价格,这个接口按地址限流。 +- 写作提示补上指南里的天气、六个维度和要避免的说法。字数与质检共用同一组数字。质检不再把 `executed` 这类纯状态词当成证据。 +- 真实模型仍然没跑。见 `BLOCKED.md`。 +- 本轮自测:`tsc --noEmit` 0 错;`npm run lint` 0 error、122 条既有 warning;`npm run test:db` 87 项,通过 74、失败 13、取消 0。这 13 条名称与 `tools/agent-loop/windows-known-failures.txt` 逐条相同。新增的合盘数据库测试在这 74 条通过里。全量 `npm test`、`next build`、全量 pytest、Python 快速门本轮没有跑。 diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 047fa425..7a405ff8 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { AccountDeletionGate } from "@/components/account-deletion-gate"; import { AppSidebar } from "@/components/app-sidebar"; @@ -13,6 +13,17 @@ function AppShell({ children }: { children: ReactNode }) { const list = useSessionList(); const registration = useShellRegistration(); const { sessions, account } = sessionListSidebarModel(list, registration); + const [compatibilityEnabled, setCompatibilityEnabled] = useState(false); + useEffect(() => { + const controller = new AbortController(); + void fetch("/api/product-access", { cache: "no-store", signal: controller.signal }) + .then((response) => response.ok ? response.json() : null) + .then((payload: { compatibility?: boolean } | null) => { + if (payload?.compatibility === true) setCompatibilityEnabled(true); + }) + .catch(() => undefined); + return () => controller.abort(); + }, []); return ( @@ -24,6 +35,7 @@ function AppShell({ children }: { children: ReactNode }) { account={account} settled={list.settled} signedOut={list.signedOut} + compatibilityEnabled={compatibilityEnabled} controls={registration?.controls} /> supabase .from("personal_reports") .select(REPORT_LIST_COLUMNS) .eq("user_id", user.id); - listQuery = reportType === "synastry_full" - ? listQuery.eq("report_type", "synastry_full") - : subjectId === "self" - ? listQuery.is("chart_profile_id", null) - : listQuery.or(`chart_profile_id.eq.${subjectId},partner_chart_profile_id.eq.${subjectId},subject_low.eq.${subjectId},subject_high.eq.${subjectId}`); - listQuery = listQuery - .order("created_at", { ascending: false }) - .order("id", { ascending: false }) - .limit(limit + 1); - if (cursor) listQuery = listQuery.or(reportCursorFilter(cursor)); - const { data: pageRows, error } = await listQuery; - if (error) throw error; + const finish = (query: T) => { + const page = query + .order("created_at", { ascending: false }) + .order("id", { ascending: false }) + .limit(limit + 1); + return cursor ? page.or(reportCursorFilter(cursor)) : page; + }; + let pageRows: unknown[] = []; + if (reportType === "synastry_full" || subjectId === "self") { + const filtered = reportType === "synastry_full" + ? listed().eq("report_type", "synastry_full") + : listed().is("chart_profile_id", null); + const { data, error } = await finish(filtered); + if (error) throw error; + pageRows = Array.isArray(data) ? data : []; + } else { + // Four equality filters, merged here. A single `.or()` would hide the + // chart_profile_id equality the list contract already asserts, and both + // people still need to see a pair report. + const results = await Promise.all([ + finish(listed().eq("chart_profile_id", subjectId)), + finish(listed().eq("partner_chart_profile_id", subjectId)), + finish(listed().eq("subject_low", subjectId)), + finish(listed().eq("subject_high", subjectId)), + ]); + const merged = new Map(); + for (const result of results) { + if (result.error) throw result.error; + for (const row of Array.isArray(result.data) ? result.data : []) { + if (!row || typeof row !== "object") continue; + const id = typeof (row as { id?: unknown }).id === "string" ? (row as { id: string }).id : ""; + if (id && !merged.has(id)) merged.set(id, row as { created_at?: unknown; id?: unknown }); + } + } + pageRows = [...merged.values()].sort((left, right) => { + const byTime = String(right.created_at ?? "").localeCompare(String(left.created_at ?? "")); + return byTime || String(right.id ?? "").localeCompare(String(left.id ?? "")); + }).slice(0, limit + 1); + } const nextCursor = nextReportCursor(Array.isArray(pageRows) ? pageRows : [], limit); const data = Array.isArray(pageRows) ? pageRows.slice(0, limit) : []; const reports = data.map((row) => listReportView(row)).filter((report) => report.id); diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 51dc3f28..415442f6 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -101,6 +101,8 @@ export type AppSidebarProps = { settled?: boolean; /** Read-only mode: no session, so the footer offers 去登录 instead of an account. */ signedOut?: boolean; + /** Product flag from the shell. Absent means hidden, so the entry does not flash before the flag arrives. */ + compatibilityEnabled?: boolean; controls?: AppSidebarControls; }; @@ -119,23 +121,13 @@ export function AppSidebar({ account, settled = true, signedOut = false, + compatibilityEnabled = false, controls, }: AppSidebarProps) { const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); /* Null outside an app-router context (and in unit tests that mount this component directly), even though the type says string. */ const pathname = usePathname() ?? ""; - const [compatibilityOpen, setCompatibilityOpen] = useState(true); - useEffect(() => { - const controller = new AbortController(); - void fetch("/api/product-access", { cache: "no-store", signal: controller.signal }) - .then((response) => response.ok ? response.json() : null) - .then((payload: { compatibility?: boolean } | null) => { - if (payload?.compatibility === false) setCompatibilityOpen(false); - }) - .catch(() => undefined); - return () => controller.abort(); - }, []); const firstSessionRef = useRef(null); const historyHeadingRef = useRef(null); const loadMoreRef = useRef(null); @@ -303,7 +295,7 @@ export function AppSidebar({ )} - {NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityOpen).map(({ href, label, icon: Icon }) => ( + {NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => ( { + console.error(`[synastry-english] ${error instanceof Error ? error.name : "failed"}`); + }); return { recovery, outcome: "idle", jobId: null }; } const outcome = await processClaimed(job); diff --git a/frontend/src/lib/synastry-full-english.ts b/frontend/src/lib/synastry-full-english.ts index 61b172b3..3166eb54 100644 --- a/frontend/src/lib/synastry-full-english.ts +++ b/frontend/src/lib/synastry-full-english.ts @@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto"; import { completeUsage } from "@/lib/consultation-billing"; import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core"; -const ENGLISH_LEASE_MS = 10 * 60 * 1000; +/** One section writer waits 90s. Nine sections run three at a time, and a failed section is written twice. */ +export const ENGLISH_GENERATION_LIMIT_MS = 2 * Math.ceil(9 / 3) * 90_000; +/** The lease is twice that ceiling, so one English report is not reclaimed while it is still writing. */ +export const ENGLISH_LEASE_MS = ENGLISH_GENERATION_LIMIT_MS * 2; type DbError = { message?: string; code?: string } | null; type QueryResult = { data: unknown; error: DbError }; diff --git a/frontend/src/lib/synastry-full-run.ts b/frontend/src/lib/synastry-full-run.ts index 63c45fea..3262a35c 100644 --- a/frontend/src/lib/synastry-full-run.ts +++ b/frontend/src/lib/synastry-full-run.ts @@ -266,11 +266,13 @@ export async function runIdleSynastryEnglish(admin: UsageClient, now = new Date( } async function runEnglishOnce(admin: UsageClient, now: Date): Promise { - const claimed = await claimEnglishJob(admin, now); - if (!claimed) return null; - const { reportId, userId } = claimed; + let reportId = ""; const started = Date.now(); try { + const claimed = await claimEnglishJob(admin, now); + if (!claimed) return null; + reportId = claimed.reportId; + const { userId } = claimed; 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") { @@ -336,6 +338,7 @@ async function runEnglishOnce(admin: UsageClient, now: Date): Promise undefined); return null; diff --git a/frontend/src/lib/synastry-full-sections.ts b/frontend/src/lib/synastry-full-sections.ts index 2f906c0f..e6c50067 100644 --- a/frontend/src/lib/synastry-full-sections.ts +++ b/frontend/src/lib/synastry-full-sections.ts @@ -44,7 +44,7 @@ const SECTION_BRIEFS: Readonly> = { const BACKGROUND_SECTIONS = new Set(["emotion", "safety", "conflict", "daily"]); /** Exact birth-data key names. Substring matches delete normal keys such as relationship_axis. */ -const PRIVATE_KEY_NAMES = new Set([ +export const PRIVATE_KEY_NAMES = new Set([ "year", "month", "day", "hour", "minute", "second", "lat", "lon", "latitude", "longitude", "tz", "timezone", "time_zone", "utc_offset", @@ -52,6 +52,13 @@ const PRIVATE_KEY_NAMES = new Set([ "birth_date", "birth_time", "birthDate", "birthTime", "date_of_birth", "time_of_birth", ]); +export const SYNASTRY_LENGTH = { + zhSection: 120, + zhTotal: 1800, + enSection: 80, + enTotal: 1100, +} as const; + export const EVIDENCE_LABELS = [ "multi_system_consensus", "single_system_inference", @@ -166,18 +173,24 @@ const ZH_EXAMPLE = "你们这段关系的底色是「有吸引,但节奏不同 const EN_EXAMPLE = "The feel of this relationship is attraction with two different speeds. At first each of you notices something the other seems to carry. Once daily life starts, the difference shows: one person shows care by doing things, the other needs it said. Under pressure one moves to fix it and the other steps back. That is not a fault. The two of you feel safe at different distances."; const ZH_SYSTEM = `你在写一份婚恋合盘的关系画像。把盘面翻译成两个人相处时的质地,不要做判决。 +先给天气,不给判决。天气可以是顺滑、慢热、拉扯、互补、共振,或需要经营。 +六个维度都用关系语言,不用术语:情感节奏说关心的方式和情绪上来时谁要说开、谁要先消化;沟通说讲道理时听得懂,情绪一上来容易各说各的;安全感说靠日常回应,边界需要练习;冲突说吵完谁先递台阶,退一步不是不在乎;生活节律说恋爱感和过日子是两套节奏,金钱、家庭和时间会更早出现;长期方向说潜力不会自动兑现,要双方愿意把关系放在更优先的位置。 五条规则:说「你们之间」,不说「你 / 他是什么人」。说倾向,不说必然。说需求,不说缺点。给条件,不给结论。保留双方主体性,走不走仍然是两个人的选择。 +不要报分数。不要用「克 / 煞 / 凶 / 吉」贴标签。不要说「注定」「必然」「命中」。不要把一方说成问题源。不要替当事人做决定。 示例:${ZH_EXAMPLE} 每个判断给两到三条分析。盘面行必须逐字来自证据里的某个完整字段,不要改写。证据互相冲突时把 label 降为 parameter_sensitive 或 blocked,并写成条件,不要写成应期。不要点名补充方法。 结论、所以、时间线的阶段名和描述、共同练习里不得出现星曜、宫位、星座、分盘、大运、分数、Koota,也不得出现「克 / 吉 / 注定 / 不合 / 适合结婚」。不要写出生日期、时间、地点。不要写某年结婚、领证或分开。第一句不要写成「这段关系的核心不是……」。 -质检硬规则:label 只能是 ${EVIDENCE_LABELS.join("、")}。中文每一节结论至少 120 字,七节合计至少 1800 字。evidence 必须等于该节证据 JSON 里的某个完整字符串字段。`; +质检硬规则:label 只能是 ${EVIDENCE_LABELS.join("、")}。中文每一节结论至少 ${SYNASTRY_LENGTH.zhSection} 字,七节合计至少 ${SYNASTRY_LENGTH.zhTotal} 字。evidence 必须等于该节证据 JSON 里的某个完整字符串字段。`; const EN_SYSTEM = `You write a relationship portrait of two people. Translate the evidence into how the relationship feels. Do not judge the people. +Open with the weather of the relationship: smooth, slow, pulling, complementary, resonant, or something that needs tending. Do not open with a verdict. +Cover six dimensions in relationship language: how care is shown and how emotion is digested; how talk holds until feelings rise; how safety comes from daily replies and where the boundary needs practice; who offers the step back after a fight; how romance and daily life run on different clocks; how a long future stays possible only when both people make room for it. Five rules: speak about the pair, not about who someone is. Speak in tendencies, not inevitability. Speak about needs, not faults. Give conditions, not a verdict. Keep both people as agents. +Do not quote a score. Do not call one person the problem. Do not decide for them. Example: ${EN_EXAMPLE} Give two or three supports for each judgment. The chart line must copy one complete evidence field verbatim. When supports conflict, lower the label to parameter_sensitive or blocked and write a condition, not a dated event. Do not name supplementary methods. The conclusion, the "so" line, the timeline phase and description, and the shared practices must not name planets, houses, signs, nakshatras, divisional charts, dashas, scores, koota, kuja, nadi, lagna, UL, or DK. 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". -Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section is at least 80 words, and the seven sections together are at least 1100 words. evidence must equal one complete string field from that section's evidence JSON.`; +Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section is at least ${SYNASTRY_LENGTH.enSection} words, and the seven sections together are at least ${SYNASTRY_LENGTH.enTotal} words. evidence must equal one complete string field from that section's evidence JSON.`; export function systemPrompt(language: "zh" | "en"): string { return language === "zh" ? ZH_SYSTEM : EN_SYSTEM; diff --git a/frontend/src/lib/synastry-full-technical.ts b/frontend/src/lib/synastry-full-technical.ts index 7cd70dde..898c538e 100644 --- a/frontend/src/lib/synastry-full-technical.ts +++ b/frontend/src/lib/synastry-full-technical.ts @@ -45,6 +45,14 @@ function named(value: unknown): string { return text(row.planet) || text(row.sign) || text(row.name) || text(row.lord); } +export function executionBasisLines(receipt: unknown): string[] { + const row = record(receipt) ?? {}; + return [ + ...linesOf(row.individual_technique_audit), + ...linesOf(row.pair_execution_tasks), + ]; +} + function linesOf(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.flatMap((row) => { @@ -56,18 +64,19 @@ function linesOf(value: unknown): string[] { }); } -function structureLine(name: string, snapshot: Record): string { - const planets = record(snapshot.planets) ?? {}; - const d9 = record(snapshot.d9) ?? record(snapshot.D9) ?? {}; +function structureLine(name: string, snapshot: Record, local: Record): string { + const planets = { ...(record(snapshot.planets) ?? {}), ...(record(local.planets) ?? {}) }; + const d9 = record(local.d9) ?? record(snapshot.d9) ?? record(snapshot.D9) ?? {}; + const ascendant = record(local.ascendant) ?? record(snapshot.ascendant); const slots: Array<[string, string]> = [ - ["上升", text(record(snapshot.ascendant)?.sign) || text(snapshot.ascendant) || "未标明"], + ["上升", text(ascendant?.sign) || text(local.ascendant) || text(snapshot.ascendant) || "未标明"], ["金星", text(record(planets.Venus)?.sign) || text(planets.Venus) || "未标明"], ["月亮", text(record(planets.Moon)?.sign) || text(planets.Moon) || "未标明"], - ["7宫主", named(snapshot.seventh_lord) || named(snapshot.house_7_lord) || "未标明"], - ["DK", named(snapshot.darakaraka) || named(snapshot.DK) || "未标明"], - ["UL", named(snapshot.upapada) || named(snapshot.UL) || "未标明"], - ["A7", named(snapshot.A7) || named(snapshot.darapada) || "未标明"], - ["D9", named(d9.ascendant) || named(snapshot.d9_ascendant) || "未标明"], + ["7宫主", named(local.seventh_lord) || named(snapshot.seventh_lord) || named(snapshot.house_7_lord) || "未标明"], + ["DK", named(local.darakaraka) || named(snapshot.darakaraka) || named(snapshot.DK) || "未标明"], + ["UL", named(local.upapada) || named(snapshot.upapada) || named(snapshot.UL) || "未标明"], + ["A7", named(local.A7) || named(snapshot.A7) || named(snapshot.darapada) || "未标明"], + ["D9", named(d9.ascendant) || named(local.d9_ascendant) || named(snapshot.d9_ascendant) || "未标明"], ]; return `${name}:${slots.map(([label, value]) => `${label} ${value}`).join(";")}`; } @@ -83,6 +92,7 @@ export function buildTechnicalBasis(input: Readonly<{ const profile = record(dossier.calculation_profile) ?? {}; const receipt = record(dossier.execution_receipt) ?? {}; const snapshots = record(dossier.chart_snapshots) ?? {}; + const structures = record(dossier.local_structures) ?? {}; const match = record(dossier.marriage_match) ?? {}; const parameters = [ `岁差:${text(profile.ayanamsa) || "未标明"}`, @@ -91,8 +101,8 @@ export function buildTechnicalBasis(input: Readonly<{ "出生时间精度:以各自档案里已经保存的时间为准,这里不显示日期和地点", ]; const structure = (["a", "b"] as const).map((side) => { - const snapshot = record(snapshots[side === "a" ? "person_a" : "person_b"]) ?? {}; - return structureLine(input.names[side], snapshot); + const key = side === "a" ? "person_a" : "person_b"; + return structureLine(input.names[side], record(snapshots[key]) ?? {}, record(structures[key]) ?? {}); }); const raman = record(input.raman); const traditional = [ @@ -103,8 +113,7 @@ export function buildTechnicalBasis(input: Readonly<{ ...linesOf(record(raman?.marriage_match)?.items ?? raman?.items), ]; const execution = [ - ...linesOf(receipt.individual_technique_audit), - ...linesOf(receipt.pair_execution_tasks), + ...executionBasisLines(receipt), "外部资料与真实案例核对:未进行,本报告是计算层归纳", ]; return [ diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index c97af677..6ece49c6 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -165,14 +165,24 @@ test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的 // 我的报告,且「我的报告」未改名)一字未改。 const header = sidebar.slice(sidebar.indexOf("")); const newChat = header.indexOf("新建对话"); - const navPages = header.indexOf("NAV_PAGES.map"); + // 原值:header.indexOf("NAV_PAGES.map"),导航无条件渲染。 + // 新值:header.indexOf("NAV_PAGES.filter"),过滤之后仍然 .map。 + // 原因:婚恋合盘按外壳传入的开关显示,入口仍排在「新建对话」之后。 + const navPages = header.indexOf("NAV_PAGES.filter"); assert.ok(newChat >= 0 && navPages > newChat); + assert.ok(header.indexOf(".map(") > navPages); const pages = sidebar.slice(sidebar.indexOf("const NAV_PAGES"), sidebar.indexOf("] as const;")); const chart = pages.indexOf('"星盘"'); const ephemeris = pages.indexOf('"星历"'); const reports = pages.indexOf('"我的报告"'); assert.ok(chart >= 0 && ephemeris > chart && reports > ephemeris); + // 原值:NAV_PAGES 在「我的报告」之后直接是「星盘档案」。 + // 新值:「婚恋合盘」插在这两项之间,前三项顺序不变。 + // 原因:S7 要求新入口按原单位置加入,不打乱星盘、星历、我的报告。 + const synastry = pages.indexOf('"婚恋合盘"'); + const people = pages.indexOf('"星盘档案"'); + assert.ok(synastry > reports && people > synastry); // 原值:`leaveChat("/chart")` / `leaveChat("/ephemeris")` —— 两个会走 // `window.location.assign(path)` 的按钮回调。 // 新值:三个页面项统一由 `NAV_PAGES` 渲染成 ``,并追加 diff --git a/frontend/tests/chart-view-zero-year.test.ts b/frontend/tests/chart-view-zero-year.test.ts index 3f31177e..3df0e01f 100644 --- a/frontend/tests/chart-view-zero-year.test.ts +++ b/frontend/tests/chart-view-zero-year.test.ts @@ -13,9 +13,8 @@ import { buildChartView, type ChartViewProfileInput } from "../src/lib/chart-vie Object.assign(globalThis, { React }); /** - * Generated from the real engine, not a hand-built shape: - * C:\Users\74082\anaconda3\python.exe - * JyotishAPIHandler.__new__()._compute_chart + _compute_chara_dasha + * Generated from the real engine, not a hand-built shape, via the configured + * Python and JyotishAPIHandler.__new__()._compute_chart + _compute_chara_dasha. * 1985-08-03 03:59, lat 39.9042, lon 116.4074, tz 8, ayanamsa raman, node_mode mean, * skip_vedastro_main_entry_overview, antardasha true. * Sagittarius duration_years is 0 (debilitated). scripts/jaimini.py was not modified. diff --git a/frontend/tests/database-synastry-full-report.test.ts b/frontend/tests/database-synastry-full-report.test.ts new file mode 100644 index 00000000..cb1e875e --- /dev/null +++ b/frontend/tests/database-synastry-full-report.test.ts @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const userId = "82000000-0000-4000-8000-000000000082"; +const personId = "82000000-0000-4000-8000-000000000182"; +const partnerId = "82000000-0000-4000-8000-000000000282"; +const reportId = "82000000-0000-4000-8000-000000000382"; +const secondReportId = "82000000-0000-4000-8000-000000000482"; +const duplicateReportId = "82000000-0000-4000-8000-000000001282"; +const changedReportId = "82000000-0000-4000-8000-000000000582"; +const requestId = "82000000-0000-4000-8000-000000000682"; +const secondRequestId = "82000000-0000-4000-8000-000000000782"; +const changedRequestId = "82000000-0000-4000-8000-000000000882"; +const refundRequestId = "82000000-0000-4000-8000-000000000982"; +const settleRequestId = "82000000-0000-4000-8000-000000001082"; +const releaseRequestId = "82000000-0000-4000-8000-000000001182"; +const fingerprint = "a".repeat(64); +const otherFingerprint = "b".repeat(64); +const changedFingerprint = "c".repeat(64); +const rulesVersion = "bde5d615:marriage_match_product_v1"; +const skillHash = "d".repeat(64); + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8", stdio: "ignore" }).status === 0; +} + +function lastLine(value: string): string { + const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return lines[lines.length - 1] ?? ""; +} + +test("synastry reports reuse a pair, drop with the person, and settle the synastry.full reservation", { + skip: dockerAvailable() ? false : "docker unavailable on this host", +}, () => { + const fixture = startPostgresFixture(); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, + }); + assert.equal(migration.status, 0, `${migration.stdout}${migration.stderr}`); + + fixture.psql(` + insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${userId}', 'Fictional Synastry', '${userId}@example.invalid', true, now()); + update public.profiles set credits = 10 where id = '${userId}'; + insert into public.chart_profiles (id, user_id, role, profile) + values + ('${personId}', '${userId}', 'other', '{}'::jsonb), + ('${partnerId}', '${userId}', 'other', '{}'::jsonb); + `); + + const insertReport = (id: string, request: string, lowPrint: string) => ` + insert into public.personal_reports ( + id, user_id, chart_profile_id, partner_chart_profile_id, request_id, request_fingerprint, + report_type, status, schema_version, presentation_mode, requested_themes, depth, + skill_name, skill_version, skill_source_commit, skill_snapshot_sha256, + subject_low, subject_high, fingerprint_low, fingerprint_high, synastry_rules_version + ) values ( + '${id}', '${userId}', '${personId}', '${partnerId}', '${request}', '${fingerprint}', + 'synastry_full', 'generating', 'synastry_report.v1', 'default', array[]::text[], 'standard', + 'jyotish-full-synastry-reading', '1.0.0', '${"e".repeat(40)}', '${skillHash}', + '${personId}', '${partnerId}', '${lowPrint}', '${otherFingerprint}', '${rulesVersion}' + ); + `; + const closeJob = ` + update public.personal_report_jobs + set status = 'cancelled', finished_at = now() + where user_id = '${userId}' and status = 'queued'; + `; + fixture.psql(insertReport(reportId, requestId, fingerprint)); + fixture.psql(` + update public.personal_reports + set status = 'ready', completed_at = now(), report_document = '{}'::jsonb, + calculation_hash = '${skillHash}', evidence_hash = '${skillHash}' + where id = '${reportId}'; + ${closeJob} + `); + assert.throws(() => fixture.psql(` + begin; + ${insertReport(duplicateReportId, secondRequestId, fingerprint)} + update public.personal_reports + set status = 'ready', completed_at = now(), report_document = '{}'::jsonb, + calculation_hash = '${skillHash}', evidence_hash = '${skillHash}' + where id = '${duplicateReportId}'; + commit; + `), (error: unknown) => { + const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr) : ""; + return /personal_reports_synastry_pair_ready_uidx/.test(`${stderr}\n${String(error)}`); + }); + assert.equal(lastLine(fixture.psql(` + select count(*) from public.personal_reports + where user_id = '${userId}' and fingerprint_low = '${fingerprint}' and status = 'ready'; + `)), "1"); + + fixture.psql(insertReport(changedReportId, changedRequestId, changedFingerprint)); + fixture.psql(` + update public.personal_reports + set status = 'ready', completed_at = now(), report_document = '{}'::jsonb, + calculation_hash = '${skillHash}', evidence_hash = '${skillHash}' + where id = '${changedReportId}'; + update public.personal_report_jobs + set status = 'cancelled', finished_at = now() + where user_id = '${userId}' and status = 'queued'; + `); + assert.equal(lastLine(fixture.psql(` + select count(*) from public.personal_reports + where user_id = '${userId}' and fingerprint_low = '${changedFingerprint}' and status = 'ready'; + `)), "1"); + + assert.equal( + fixture.psql(`select success::text || '|' || credits::text from public.authorize_usage('${userId}','synastry.full',null,'${settleRequestId}',2)`), + "true|8", + ); + assert.equal( + fixture.psql(`select success::text from public.complete_usage('${userId}','${settleRequestId}','{"actualModelId":"standard-model","inputTokens":1,"outputTokens":1,"costMicrousd":1,"durationMs":1}'::jsonb)`), + "true", + ); + assert.equal(fixture.psql(`select status from public.usage_reservations where request_id = '${settleRequestId}'`), "completed"); + assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "8"); + + assert.equal( + fixture.psql(`select success::text || '|' || credits::text from public.authorize_usage('${userId}','synastry.full',null,'${releaseRequestId}',2)`), + "true|6", + ); + assert.equal( + fixture.psql(`select success::text || '|' || credits::text from public.release_usage('${userId}','${releaseRequestId}','generation failed')`), + "true|8", + ); + assert.equal(fixture.psql(`select status from public.usage_reservations where request_id = '${releaseRequestId}'`), "released"); + + assert.equal( + fixture.psql(`select success::text || '|' || credits::text from public.authorize_usage('${userId}','synastry.full',null,'${refundRequestId}',2)`), + "true|6", + ); + fixture.psql(` + insert into public.personal_reports ( + id, user_id, request_id, request_fingerprint, report_type, status, + schema_version, presentation_mode, requested_themes, depth, + skill_name, skill_version, skill_source_commit, skill_snapshot_sha256 + ) values ( + '${secondReportId}', '${userId}', '${refundRequestId}', '${fingerprint}', 'synastry_full', 'generating', + 'synastry_report.v1', 'default', array[]::text[], 'standard', + 'jyotish-full-synastry-reading', '1.0.0', '${"e".repeat(40)}', '${skillHash}' + ); + delete from public.personal_reports where id = '${secondReportId}'; + update public.personal_report_jobs + set status = 'cancelled', finished_at = now() + where user_id = '${userId}' and status = 'queued'; + `); + assert.equal(fixture.psql(`select status from public.usage_reservations where request_id = '${refundRequestId}'`), "released"); + assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "8"); + + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + `set role authenticated; select set_config('request.jwt.claim.sub', '${userId}', true); select public.delete_chart_subject('${personId}');`, + ); + assert.equal(fixture.psql(`select count(*) from public.personal_reports where user_id = '${userId}' and report_type = 'synastry_full'`), "0"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/personal-report-worker.test.ts b/frontend/tests/personal-report-worker.test.ts index 61e0376f..30e15e9a 100644 --- a/frontend/tests/personal-report-worker.test.ts +++ b/frontend/tests/personal-report-worker.test.ts @@ -696,6 +696,32 @@ test("terminal durable generation failure releases the reserved report usage", a assert.equal(harness.billingCompleteCount, 0); }); +test("a synastry report rejected by the reader gate releases the reservation", async () => { + const billing: ReportBillingPort = { + async reserve() { throw new Error("not used"); }, + async complete() { throw new Error("complete must not run"); }, + async release(input) { + assert.equal(input.userId, USER_ID); + assert.equal(input.requestId, REQUEST_ID); + return true; + }, + }; + const harness = createHarness({ + billing, + report: reportRecord({ reportType: "synastry_full" }), + generate: async () => ({ + status: "failed", + failureCode: "report_guard_rejected", + }), + }); + + const result = await harness.worker.tick(); + + assert.equal(result.outcome, "failed"); + assert.deepEqual(harness.billingReleaseReasons, ["report_guard_rejected"]); + assert.equal(harness.billingCompleteCount, 0); +}); + test("retryable durable generation failure does not release before retry exhaustion", async () => { const billing: ReportBillingPort = { async reserve() { throw new Error("not used"); }, diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index 71b91aec..b20e80a7 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -317,6 +317,12 @@ test("keeps app sidebar props as product data and callbacks", () => { // 原因: D8 puts the archive beside 星盘 / 星历 / 我的报告. assert.doesNotMatch(appSidebar, /onOpenChartLibrary/); assert.match(appSidebar, /href: "\/people", label: "星盘档案"/); + // 原值:导航到「星盘档案」为止,侧栏不接收合盘开关。 + // 新值:「婚恋合盘」接在「我的报告」之后,由 compatibilityEnabled 决定是否渲染,默认不显示。 + // 原因:S7 要求侧栏只吃 props,开关由外壳传入。 + assert.match(appSidebar, /href: "\/synastry", label: "婚恋合盘"/); + assert.match(appSidebar, /compatibilityEnabled\?: boolean/); + assert.match(appSidebar, /compatibilityEnabled = false/); assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i); }); diff --git a/frontend/tests/synastry-full-create-route.test.ts b/frontend/tests/synastry-full-create-route.test.ts new file mode 100644 index 00000000..d29bc1ef --- /dev/null +++ b/frontend/tests/synastry-full-create-route.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const frontendRoot = fileURLToPath(new URL("../", import.meta.url)); +const otherId = "11111111-1111-4111-8111-111111111111"; +const selfRow = { + name: "虚构甲", + birth_date: "1991-04-07", + reported_birth_time: "09:15:00", + birth_time_status: "reported", + latitude: 31.2, + longitude: 121.4, + timezone_offset: 8, + timezone_id: "Asia/Shanghai", + ayanamsa: "lahiri", +}; + +function runRoute(script: string): { status: number; body: Record; charged: boolean } { + const result = spawnSync(process.execPath, [ + "--experimental-test-module-mocks", + "--import", + "tsx", + "--input-type=module", + "--eval", + script, + ], { cwd: frontendRoot, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const line = result.stdout.trim().split("\n").at(-1) ?? "{}"; + return JSON.parse(line) as { status: number; body: Record; charged: boolean }; +} + +function script(input: { product: boolean; same: boolean; missingOther: boolean; reused: boolean }): string { + return String.raw` + import { mock } from "node:test"; + import { pathToFileURL } from "node:url"; + const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; + const selfRow = ${JSON.stringify(selfRow)}; + const otherId = ${JSON.stringify(otherId)}; + let charged = false; + mock.module("server-only", { namedExports: {} }); + mock.module("@/mastra", { namedExports: { runConsultationWorkflow() { throw new Error("mastra"); } } }); + mock.module("@/mastra/personal-report", { namedExports: { + PersonalReportAgentOutputError: class PersonalReportAgentOutputError extends Error {}, + createPersonalReportAgent() { throw new Error("agent"); }, + } }); + mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => ${input.product ? "true" : "false"} } }); + mock.module("@/lib/consultation-billing", { namedExports: { + authorizeUsage() { charged = true; return { success: true }; }, + completeUsage() { charged = true; }, + releaseUsage() {}, + } }); + mock.module("@/lib/personal-report-service", { namedExports: { + createSupabasePersonalReportService: () => ({ getOwnedById: async () => null }), + createPersonalReportDataClient: () => ({ from: () => ({}) }), + } }); + function query(table) { + const filters = []; + const q = { + select() { return q; }, + eq(column, value) { filters.push([column, value]); return q; }, + in() { return q; }, + gte() { return q; }, + is() { return q; }, + or() { return q; }, + order() { return q; }, + limit() { return q; }, + maybeSingle() { + if (table === "profiles") return Promise.resolve({ data: selfRow, error: null }); + if (table === "chart_profiles") { + return Promise.resolve({ data: ${input.missingOther ? "null" : "{ ...selfRow, id: otherId, user_id: 'user-1', role: 'other' }"}, error: null }); + } + if (table === "personal_reports") { + return Promise.resolve({ data: ${input.reused ? "{ id: 'ready-1', request_id: 'req-1', status: 'ready', created_at: '2026-10-19T00:00:00Z' }" : "null"}, error: null }); + } + return Promise.resolve({ data: null, error: null }); + }, + then(resolve, reject) { return Promise.resolve({ data: [], error: null }).then(resolve, reject); }, + }; + return q; + } + const client = { auth: { getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }) }, from: (table) => query(table) }; + mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => client } }); + mock.module("@/lib/supabase/admin", { namedExports: { createAdminSupabaseClient: () => client } }); + const reports = await import(moduleUrl("src/app/api/reports/route.ts")); + const response = await reports.POST(new Request("https://staging.jyotisha.chat/api/reports", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + reportType: "synastry_full", + subjectA: "self", + subjectB: ${input.same ? '"self"' : "otherId"}, + }), + })); + console.log(JSON.stringify({ status: response.status, body: await response.json(), charged })); + `; +} + +test("the same person is rejected before billing", () => { + const result = runRoute(script({ product: true, same: true, missingOther: false, reused: false })); + assert.equal(result.status, 400); + assert.equal(result.body.code, "same_subject"); + assert.equal(result.charged, false); +}); + +test("someone else's id is rejected before billing", () => { + const result = runRoute(script({ product: true, same: false, missingOther: true, reused: false })); + assert.equal(result.status, 409); + assert.equal(result.charged, false); + assert.match(JSON.stringify(result.body), /不会扣点/); +}); + +test("an unchanged pair is reused without a new reservation", () => { + const result = runRoute(script({ product: true, same: false, missingOther: false, reused: true })); + assert.equal(result.status, 200); + assert.equal(result.body.reused, true); + assert.equal(result.charged, false); +}); + +test("a closed product switch rejects the report before billing", () => { + const result = runRoute(script({ product: false, same: false, missingOther: true, reused: false })); + assert.equal(result.status, 503); + assert.equal(result.body.code, "compatibility_product_disabled"); + assert.equal(result.charged, false); +}); + +test("the english route stays closed when the product switch is off", () => { + const scriptText = String.raw` + import { mock } from "node:test"; + import { pathToFileURL } from "node:url"; + const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; + mock.module("server-only", { namedExports: {} }); + mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => false } }); + mock.module("@/lib/supabase/server", { namedExports: { + createServerSupabaseClient: async () => ({ auth: { getUser: async () => ({ data: { user: { id: "user-1" } } }) } }), + } }); + mock.module("@/lib/supabase/admin", { namedExports: { createAdminSupabaseClient: () => ({}) } }); + mock.module("@/lib/personal-report-service", { namedExports: { + createSupabasePersonalReportService: () => ({ getOwnedById: async () => null }), + } }); + const route = await import(moduleUrl("src/app/api/reports/[reportId]/english/route.ts")); + const response = await route.POST(new Request("https://staging.jyotisha.chat/api/reports/11111111-1111-4111-8111-111111111111/english", { method: "POST" }), { + params: Promise.resolve({ reportId: "11111111-1111-4111-8111-111111111111" }), + }); + console.log(JSON.stringify({ status: response.status, body: await response.json(), charged: false })); + `; + const result = runRoute(scriptText); + assert.equal(result.status, 503); + assert.equal(result.body.code, "compatibility_product_disabled"); +}); diff --git a/frontend/tests/synastry-full-english-job.test.ts b/frontend/tests/synastry-full-english-job.test.ts index c7c1bac0..d1d541d2 100644 --- a/frontend/tests/synastry-full-english-job.test.ts +++ b/frontend/tests/synastry-full-english-job.test.ts @@ -4,6 +4,8 @@ import test from "node:test"; import { claimEnglishJob, + ENGLISH_GENERATION_LIMIT_MS, + ENGLISH_LEASE_MS, failSynastryEnglishJob, recordSynastryModelUsage, requeueSynastryEnglish, @@ -57,6 +59,10 @@ test("english claim writes a lease, reclaims an expired run, and fails a missing assert.equal(claimed[0]?.status, "running"); assert.equal(claimed[0]?.attempt_count, 1); assert.equal(typeof claimed[0]?.lease_token, "string"); + assert.equal(ENGLISH_LEASE_MS, ENGLISH_GENERATION_LIMIT_MS * 2); + assert.equal(claimed[0]?.lease_expires_at, new Date(Date.parse("2026-10-19T00:00:00Z") + ENGLISH_LEASE_MS).toISOString()); + const worker = readFileSync(new URL("../src/lib/personal-report-worker-core.ts", import.meta.url), "utf8"); + assert.match(worker, /runIdleSynastryEnglish\?\.\(\)\.catch/); const expired: Record[] = []; const again = await claimEnglishJob(client([ diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 43999949..243f2e8f 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -1,15 +1,15 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import test from "node:test"; -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { readFileSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; 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 { EVIDENCE_LABELS, sectionEvidence, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts"; -import { buildTechnicalBasis } from "../src/lib/synastry-full-technical.ts"; +import { EVIDENCE_LABELS, PRIVATE_KEY_NAMES, sectionEvidence, SYNASTRY_LENGTH, systemPrompt, userPrompt, SYNASTRY_SECTION_IDS, type SynastrySectionId } from "../src/lib/synastry-full-sections.ts"; +import { buildTechnicalBasis, executionBasisLines } 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; @@ -212,11 +212,21 @@ test("a thrown section write retries only that section", async () => { test("prompts carry the gate labels and the relationship example", () => { const prompt = systemPrompt("zh"); for (const label of EVIDENCE_LABELS) assert.match(prompt, new RegExp(label)); - assert.match(prompt, /120/); - assert.match(prompt, /1800/); + assert.match(prompt, new RegExp(String(SYNASTRY_LENGTH.zhSection))); + assert.match(prompt, new RegExp(String(SYNASTRY_LENGTH.zhTotal))); + assert.match(prompt, /先给天气/); + assert.match(prompt, /六个维度都用关系语言/); + assert.match(prompt, /不要把一方说成问题源/); assert.match(prompt, /你们这段关系的底色是/); + assert.match(systemPrompt("en"), /Open with the weather/); + assert.match(systemPrompt("en"), new RegExp(String(SYNASTRY_LENGTH.enSection))); + assert.match(systemPrompt("en"), new RegExp(String(SYNASTRY_LENGTH.enTotal))); const gate = readFileSync(new URL("../../scripts/marriage_match_service/synastry_full_reader_gate.py", import.meta.url), "utf8"); for (const label of EVIDENCE_LABELS) assert.match(gate, new RegExp(`"${label}"`)); + assert.match(gate, new RegExp(`ZH_SECTION_MINIMUM = ${SYNASTRY_LENGTH.zhSection}`)); + assert.match(gate, new RegExp(`ZH_TOTAL_MINIMUM = ${SYNASTRY_LENGTH.zhTotal}`)); + assert.match(gate, new RegExp(`EN_SECTION_MINIMUM = ${SYNASTRY_LENGTH.enSection}`)); + assert.match(gate, new RegExp(`EN_TOTAL_MINIMUM = ${SYNASTRY_LENGTH.enTotal}`)); }); test("technical execution lines follow the receipt and raman is named", () => { @@ -239,20 +249,54 @@ test("technical execution lines follow the receipt and raman is named", () => { assert.equal(JSON.stringify(blocks).includes("无法计算"), false); }); -function pythonBin(): string { - const candidates = [process.env.PYTHON, "python", "C:\\Users\\74082\\anaconda3\\python.exe"].filter((item): item is string => Boolean(item)); +function pythonBin(): string | null { + const candidates = [process.env.PYTHON, "python3", "python"].filter((item): item is string => Boolean(item)); for (const candidate of candidates) { - if (candidate.includes("\\") && !existsSync(candidate)) continue; const probe = spawnSync(candidate, ["-c", "import sys"], { encoding: "utf8" }); if (probe.status === 0) return candidate; } - throw new Error("python_missing"); + return null; } -test("a public pair dossier fills every section without birth keys", { timeout: 180_000 }, () => { +function markers(value: unknown, into: Set) { + if (Array.isArray(value)) { + value.forEach((item) => markers(item, into)); + return; + } + if (!value || typeof value !== "object") return; + const row = value as Record; + if (typeof row.id === "string" && row.id) into.add(`id:${row.id}`); + if (typeof row.lane === "string" && row.lane) into.add(`lane:${row.lane}`); + for (const item of Object.values(row)) markers(item, into); +} + +function citesSource(evidence: unknown, source: ReadonlySet): boolean { + let found = false; + const walk = (value: unknown) => { + if (found || !value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach(walk); + return; + } + const row = value as Record; + if (typeof row.id === "string" && source.has(`id:${row.id}`)) found = true; + if (typeof row.lane === "string" && source.has(`lane:${row.lane}`)) found = true; + if (!found) Object.values(row).forEach(walk); + }; + walk(evidence); + return found; +} + +test("a public pair dossier fills every section without birth keys", { timeout: 180_000 }, (t) => { + const python = pythonBin(); + if (!python) { + console.log("skip: python_missing; set PYTHON or provide python3/python"); + t.skip("python_missing"); + return; + } const root = fileURLToPath(new URL("../..", import.meta.url)); const output = fileURLToPath(new URL("./synastry-full-dossier-sections.json", import.meta.url)); - execFileSync(pythonBin(), ["-c", [ + execFileSync(python, ["-c", [ "import json, sys", "from pathlib import Path", "root = Path(sys.argv[1])", @@ -263,11 +307,14 @@ test("a public pair dossier fills every section without birth keys", { timeout: "from marriage_match_service.full_dossier import build_synastry_full_dossier", "packet = build_synastry_full_dossier(JyotishComputeService(), EINSTEIN, JOBS)", "dossier = packet['dossier']", - "kept = {key: dossier.get(key) for key in ('reader_section_brief','relationship_lenses','pair_interaction_evidence','execution_receipt','marriage_match','chart_snapshots')}", + "kept = {key: dossier.get(key) for key in ('reader_section_brief','relationship_lenses','pair_interaction_evidence','execution_receipt','marriage_match','chart_snapshots','local_structures')}", "output.write_text(json.dumps(kept), encoding='utf-8')", ].join("\n"), root, output], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); - const dossier = JSON.parse(readFileSync(output, "utf8")); - const banned = new Set(["year", "month", "day", "hour", "minute", "second", "lat", "lon", "latitude", "longitude", "tz", "timezone", "time_zone", "city", "place", "location", "birth_place", "birthDate", "birthTime"]); + const dossier = JSON.parse(readFileSync(output, "utf8")) as Record; + const source = new Set(); + markers(dossier.relationship_lenses, source); + markers(dossier.pair_interaction_evidence, source); + assert.ok(source.size > 0); const segments = [ { id: "s1", start: "2026-01", end: "2027-01", basis: "Albert: jupiter mahadasha 2026-01 to 2027-01" }, { id: "s2", start: "2027-01", end: "2028-01", basis: "Albert: jupiter mahadasha 2027-01 to 2028-01" }, @@ -286,8 +333,21 @@ test("a public pair dossier fills every section without birth keys", { timeout: } }; walk(evidence); - assert.equal([...keys].some((key) => banned.has(key)), false, id); - assert.ok(JSON.stringify(evidence).length > 2, id); + assert.equal([...keys].some((key) => PRIVATE_KEY_NAMES.has(key)), false, id); + assert.equal(citesSource(evidence, source), true, id); + } + const blocks = buildTechnicalBasis({ + dossier, + names: { a: "甲", b: "乙" }, + contacts: [], + timing: [], + }); + const execution = executionBasisLines(dossier.execution_receipt); + assert.ok(execution.length > 0); + assert.deepEqual(blocks[5]?.lines.slice(0, execution.length), execution); + assert.equal(blocks[5]?.lines.at(-1), "外部资料与真实案例核对:未进行,本报告是计算层归纳"); + for (const line of blocks[1]?.lines ?? []) { + assert.equal(/上升 未标明;金星 未标明;月亮 未标明/.test(line), false, line); } rmSync(output, { force: true }); }); diff --git a/scripts/marriage_match_service/full_dossier.py b/scripts/marriage_match_service/full_dossier.py index f772b285..bd7391f9 100644 --- a/scripts/marriage_match_service/full_dossier.py +++ b/scripts/marriage_match_service/full_dossier.py @@ -96,6 +96,91 @@ def upcoming_vimshottari(reading: dict[str, Any], today: str, years: int = 5) -> return rows +_SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +_SIGN_LORDS = { + "Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon", + "Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars", + "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter", +} + + +def _reading_packet(reading: dict[str, Any]) -> dict[str, Any]: + nested = reading.get("raw_full_reading") + return nested if isinstance(nested, dict) else reading + + +def _sign_name(value: Any) -> str | None: + if isinstance(value, str) and value.strip(): + return value.strip() + if not isinstance(value, dict): + return None + for key in ("sign", "sign_name", "rasi"): + sign = value.get(key) + if isinstance(sign, str) and sign.strip(): + return sign.strip() + for key in ("longitude", "degree_raw", "lon", "degree"): + longitude = value.get(key) + if isinstance(longitude, (int, float)) and not isinstance(longitude, bool): + return _SIGNS[int(float(longitude) % 360.0) // 30] + return None + + +def _named_planet(root: Any, path: tuple[str, ...]) -> str | None: + value = root + for key in path: + value = value.get(key) if isinstance(value, dict) else None + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + planet = value.get("planet") or value.get("name") + if isinstance(planet, str) and planet.strip(): + return planet.strip() + return None + + +def relationship_structure(reading: dict[str, Any]) -> dict[str, Any]: + """Signs and relationship points the longitude-only snapshot does not carry.""" + modules = _reading_packet(reading).get("modules") + modules = modules if isinstance(modules, dict) else {} + chart = modules.get("chart") if isinstance(modules.get("chart"), dict) else {} + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + ascendant = _sign_name(chart.get("ascendant")) + seventh = _SIGN_LORDS[_SIGNS[(_SIGNS.index(ascendant) + 6) % 12]] if ascendant in _SIGN_LORDS else None + jaimini = modules.get("jaimini") if isinstance(modules.get("jaimini"), dict) else {} + darakaraka = ( + _named_planet(jaimini, ("chara_karaka_standard", "karakas", "DK")) + or _named_planet(jaimini, ("chara_karaka_8", "karaka_table_8", "Darakaraka")) + or _named_planet(jaimini, ("chara_karaka_7", "karaka_table", "Darakaraka")) + ) + special = modules.get("special_lagnas") if isinstance(modules.get("special_lagnas"), dict) else {} + arudha = modules.get("arudha_padas") if isinstance(modules.get("arudha_padas"), dict) else {} + padas = arudha.get("padas") if isinstance(arudha.get("padas"), dict) else arudha + upapada = _sign_name(special.get("Upapada_Lagna") or special.get("UL")) + if upapada is None and isinstance(padas, dict): + upapada = _sign_name(padas.get("UL")) + a7 = _sign_name(special.get("A7") or special.get("Darapada")) + if a7 is None and isinstance(padas, dict): + a7 = _sign_name(padas.get("A7")) + vargas = modules.get("varga_full") if isinstance(modules.get("varga_full"), dict) else {} + navamsa = vargas.get("D9_Navamsa") if isinstance(vargas.get("D9_Navamsa"), dict) else {} + d9 = _sign_name(navamsa.get("Ascendant") or navamsa.get("ascendant")) + return { + "ascendant": {"sign": ascendant} if ascendant else {}, + "planets": { + "Venus": {"sign": _sign_name(planets.get("Venus"))} if _sign_name(planets.get("Venus")) else {}, + "Moon": {"sign": _sign_name(planets.get("Moon"))} if _sign_name(planets.get("Moon")) else {}, + }, + "seventh_lord": seventh, + "darakaraka": darakaraka, + "upapada": upapada, + "A7": a7, + "d9": {"ascendant": d9} if d9 else {}, + } + + def build_synastry_full_dossier( handler: Any, person_a: dict[str, Any], @@ -111,6 +196,11 @@ def build_synastry_full_dossier( reading_a = future_a.result() reading_b = future_b.result() dossier = _dossier_builder()(reading_a, reading_b) + if isinstance(dossier, dict): + dossier["local_structures"] = { + "person_a": relationship_structure(reading_a if isinstance(reading_a, dict) else {}), + "person_b": relationship_structure(reading_b if isinstance(reading_b, dict) else {}), + } 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 {} diff --git a/scripts/marriage_match_service/synastry_full_reader_gate.py b/scripts/marriage_match_service/synastry_full_reader_gate.py index e0cabb81..decaea1c 100644 --- a/scripts/marriage_match_service/synastry_full_reader_gate.py +++ b/scripts/marriage_match_service/synastry_full_reader_gate.py @@ -36,6 +36,31 @@ SECTION_ORDER = ( "practices", ) PROSE_SECTIONS = SECTION_ORDER[:7] +ZH_SECTION_MINIMUM = 120 +ZH_TOTAL_MINIMUM = 1800 +EN_SECTION_MINIMUM = 80 +EN_TOTAL_MINIMUM = 1100 +STATUS_ONLY_VALUES = { + "ready_for_comparison", + "executed", + "present", + "used", + "available", + "passed", + "parameter_sensitive", + "partial", + "weak", + "observation_only", + "not_applicable", + "not_used", + "not_used_for_date_intersection", + "blocked", + "missing", + "queued", + "running", + "ready", + "failed", +} EVIDENCE_LABELS = ( "multi_system_consensus", "single_system_inference", @@ -148,7 +173,7 @@ def _string_values(value: Any) -> set[str]: found: set[str] = set() if isinstance(value, str): stripped = value.strip() - if stripped: + if stripped and stripped.lower() not in STATUS_ONLY_VALUES: found.add(stripped) elif isinstance(value, dict): for item in value.values(): @@ -231,7 +256,7 @@ def validate_synastry_reader( 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 + minimum = ZH_SECTION_MINIMUM if language == "zh" else EN_SECTION_MINIMUM 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))) @@ -290,7 +315,7 @@ def validate_synastry_reader( if not evidence or evidence not in practice_values: failures.append(_fail("practices", "evidence_missing", evidence or "item")) - total_minimum = 1800 if language == "zh" else 1100 + total_minimum = ZH_TOTAL_MINIMUM if language == "zh" else EN_TOTAL_MINIMUM total = prose_characters if language == "zh" else prose_words if total < total_minimum: failures.append(_fail("delivery", "too_short", str(total))) diff --git a/tests/test_full_synastry_dossier.py b/tests/test_full_synastry_dossier.py index 2fa9e409..7984f2be 100644 --- a/tests/test_full_synastry_dossier.py +++ b/tests/test_full_synastry_dossier.py @@ -3,6 +3,8 @@ from __future__ import annotations import importlib.util from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] MODULE_PATH = ROOT / "skills" / "jyotish-full-synastry-reading" / "scripts" / "build_full_synastry_dossier.py" @@ -116,15 +118,18 @@ def test_pair_interaction_evidence_keeps_direction_and_blocks_without_both_ascen } +# 原值: 把 270° 改成 240°,让用例在现有 aspects.py 上通过 +# 新值: 保持上游 270°,并标 strict xfail +# 原因: 相位文件不在上游快照里,不改期望去迁就它 +@pytest.mark.xfail( + reason="aspects.py folds an angle over 180 degrees before the 4th-house orb, so 270 becomes 90 and misses Mars_aspect_4. The file is not in the upstream snapshot.", + strict=True, +) def test_same_named_special_aspect_uses_directed_angle_not_planet_label(): a = _packet() b = _packet() a["modules"]["chart"]["planets"]["Mars"]["degree_raw"] = 0.0 - # 原值: 270.0,期望相位 Mars_aspect_4、方向 person_b_to_person_a - # 新值: 240.0,期望不变 - # 原因: 仓库 aspects.py 先把超过 180° 的角折回,再对 4 宫相位(120°)取容许度。 - # 270° 折成 90°,对不上;240° 折成 120°,方向仍由折回后的角对上 B 指向 A。不改 aspects.py。 - b["modules"]["chart"]["planets"]["Mars"]["degree_raw"] = 240.0 + b["modules"]["chart"]["planets"]["Mars"]["degree_raw"] = 270.0 contacts = module.build_pair_interaction_evidence(a, b)["angular_contacts"] mars_contact = next( row for row in contacts diff --git a/tests/test_prepare_full_synastry_packets.py b/tests/test_prepare_full_synastry_packets.py index 1597000b..88221ab5 100644 --- a/tests/test_prepare_full_synastry_packets.py +++ b/tests/test_prepare_full_synastry_packets.py @@ -37,10 +37,10 @@ def test_profile_normalization_requires_place_and_builds_native_full_reading_com def test_runner_has_a_fixed_preflight_before_native_calculation(): command = module.preflight_command() - # 原值: command[1].endswith("scripts/pre_work_check.py") - # 新值: 用路径部件判断文件名 - # 原因: Windows 分隔符是反斜杠,endswith 正向斜杠会失败。命令仍指向同一预检脚本。 - assert Path(command[1]).name == "pre_work_check.py" + # 原值: Path(command[1]).name == "pre_work_check.py" + # 新值: 两边都换成正斜杠后再 endswith("scripts/pre_work_check.py") + # 原因: 修复单要求保留上游的 endswith 比较,同时让 Windows 反斜杠也能对上 + assert command[1].replace("\\", "/").endswith("scripts/pre_work_check.py") assert command[command.index("--remote-timeout") + 1] == "8" assert command[command.index("--command-timeout") + 1] == "45" diff --git a/tests/test_synastry_full_reader_gate.py b/tests/test_synastry_full_reader_gate.py index 7c2e68cc..b7a3963a 100644 --- a/tests/test_synastry_full_reader_gate.py +++ b/tests/test_synastry_full_reader_gate.py @@ -155,6 +155,13 @@ def test_evidence_must_be_a_complete_field(): assert "evidence_missing" in _one(delivery, inputs) +def test_status_only_field_is_not_evidence(): + delivery, inputs = _passing("zh") + inputs["climate"]["status"] = "executed" + delivery["sections"][0]["reasons"][0]["evidence"] = "executed" + assert "evidence_missing" in _one(delivery, inputs) + + def test_d_chart_beside_chinese_fails(): delivery, inputs = _passing("zh") delivery["sections"][1]["text"] += "看D9"