diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb0535d..2ed54bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-19 — 完整合盘报告第三次打回后的修复 + +- 「我的报告」翻页恢复成一次查询,按创建时间和编号往下翻。 +- 技术依据里的 A7 改从完整星盘的实际位置读取。7 宫主、DK、UL、A7、D9 在公开名人盘上都能读到。 +- 合盘入口仍默认先藏。外壳改用已经取到的账户开关,不再每次另请求一次。Skill 版本不变(BUG-1395、BUG-1396)。 + ## 2026-10-19 — 完整数据版报告的 Niryaana Shoola 大运表改了 - 这张表不再和 Shoola 相同。起运从第二宫和第八宫里较强的星座开始;奇数星座顺行,偶数星座逆行;动、固、双星座分别是 7、8、9 年。只列主运,子运暂不列出。两宫分不出强弱时,这张表本轮不列出。合盘仍不用它做应期。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6b703c49..786dfe2e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36878,12 +36878,29 @@ - 首次发现: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` 核对同一对复用、指纹变化、删人级联,以及预留、结算、释放和生成中删除退点。 -- 防复发:不要从经度快照里读它没有的星座字段。不要在侧栏里发请求。 +- 用户现象:技术依据第二块在真实报告上每一项都是「未标明」。侧栏一挂上就去请求产品开关,和其他页面的预取清单对不上。第三轮验收时 A7 仍可能是「未标明」,外壳每次挂载还多打一次开关接口。 +- 触发条件:生成完整合盘报告后展开技术依据;或任意页面挂上外壳。验收打回见 issue #82 第二、三份修复单。 +- 根因:上游快照只给上升和行星经度。结构行却去读星座、7 宫主、DK、UL、A7、D9,这些字段不在快照里。A7 实际在 `modules.jaimini.arudha_padas`,不在 `modules.arudha_padas`;`chara_karaka_standard` 这个键不存在。侧栏为了藏「婚恋合盘」自己发了 `GET /api/product-access`。改到外壳之后,外壳仍每次单独请求,没有用已经取过的账户数据。 +- 修复:胶水层从两人的完整星盘补出这些结构字段,放进证据包的 `local_structures`。A7 改读 `jaimini.arudha_padas`,DK 改读 `chara_karakas`。侧栏改为接收 `compatibilityEnabled`,默认不显示。外壳改读账户接口里的 `compatibility`,不再单独请求开关。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 用公开名人证据包核对 7 宫主、DK、UL、A7、D9 都有值,并直接用 `execution_receipt` 的模块名和状态核对第 ⑥ 块。`tests/test_synastry_full_dossier.py` 核对 `relationship_structure()` 的这五个字段。`frontend/tests/sidebar-contract.test.ts` 核对侧栏源码不含 `fetch(`,外壳源码不含 `/api/product-access`。 +- 防复发:不要从经度快照里读它没有的星座字段。A7 不要再从 `modules.arudha_padas` 起读。不要在侧栏或外壳里另发开关请求。 - 相关记录:BUG-1394。issue #82。 - 复发自:无。 - 修复版本:分支 `codex/synastry-full-report-20261018`。 + +## BUG-1396 | 我的报告列表被拆成四次查询后翻页合同失效 + +- 状态:resolved +- 首次发现:2026-10-19 +- 最近更新:2026-10-19 +- 影响面:我的报告 +- 用户现象:报告列表往下翻时,游标不再按「创建时间、编号」各取一页。同一页可能打四次数据库,再在内存里合并。 +- 触发条件:打开「我的报告」并加载更早的一页。验收打回见 issue #82 第三份修复单,合同测试 `GET /api/reports pages by (created_at desc, id desc)`。 +- 根因:上一轮为了让「缺人物」测试的数据库替身不用实现 `.or()`,把一次人物查询拆成四次等值查询。翻页合同要求的是单次查询、`limit + 1`、再用游标。 +- 修复:列表恢复单次 `.or()` 查询和原来的排序、限量、游标。缺人物那条测试的替身补上 `.or()`,断言没有放宽。 +- 验证:`frontend/tests/report-list-paging-20260930.test.tsx` 该条通过。`frontend/tests/subject-route-birth.test.ts`「a missing report subject fails closed before billing」仍是 409 且不扣点。 +- 防复发:不要为了测试替身拆开报告列表的分页查询。替身缺方法就补方法。 +- 相关记录:BUG-1124、BUG-1395。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 e4655377..71875256 100644 --- a/docs/tasks/PROGRESS-synastry-full-report-20261018.md +++ b/docs/tasks/PROGRESS-synastry-full-report-20261018.md @@ -64,3 +64,15 @@ - 写作提示补上指南里的天气、六个维度和要避免的说法。字数与质检共用同一组数字。质检不再把 `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 快速门本轮没有跑。 + +## 2026-10-19 第三份修复单 + +对照 `docs/tasks/TASK-synastry-full-report-fix-20261019-3.md`。产品在 issue #82 选了 A。`origin/staging` 再次合并进本分支,没有 rebase。合并时合盘两条记录顺延为 BUG-1394、BUG-1395,BUG-1393 留给 #84。本轮新增 BUG-1396。 + +- 报告列表恢复单次 `.or()` 查询和原来的翻页写法。「缺人物」测试的替身补了 `.or()`,断言仍是 409 且不扣点。 +- 两轮质检都拒绝之后,预留释放,报告标为失败。英文任务补了两条行为:领取后写快照、标 ready、记用量;生成抛错后标 failed,不再停在 running。 +- 技术依据第 ⑥ 块改为直接读 `execution_receipt` 的模块名和状态。A7 改读 `jaimini.arudha_padas`,删掉不存在的 `chara_karaka_standard`。公开名人盘上 7 宫主、DK、UL、A7、D9 都有值。 +- 外壳不再单独请求开关,改读账户数据里的 `compatibility`。 +- 真实模型仍然没跑。见 `BLOCKED.md`。 +- 本轮自测:`tsc --noEmit` 0 错。改动文件的 eslint 0 error。翻页合同、缺人物 409、两轮质检退点、英文领取成功与抛错失败、侧栏合同、合盘报告 12 条(含公开名人盘)都通过。`relationship_structure()` 单测通过。 +- 全量 `npm test` 跑了约 10 分钟后退出码 1,记到第 3473 条就停了,没有最终汇总。停之前看到 57 条 `not ok`:其中 13 条是已知的 Windows 数据库失败,一批是 `G:\G:\` 双盘符找不到文件,预热那组是依赖目录解析失败。`next build`、全量 pytest、Python 快速门、单独的全量 `test:db` 本轮没有再跑。 diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 7a405ff8..7a214d92 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { AccountDeletionGate } from "@/components/account-deletion-gate"; import { AppSidebar } from "@/components/app-sidebar"; @@ -13,17 +13,7 @@ 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(); - }, []); + const compatibilityEnabled = list.account?.compatibility === true; return ( diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 355e0422..edb3ba00 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { readSelfHostedIdentityConfig } from "@/modules/identity/config"; +import { isProductEnabled } from "@/lib/product-access"; export const runtime = "nodejs"; @@ -130,6 +131,7 @@ export async function GET() { const activeSubscription = activeSubscriptions?.[0] ?? null; const isAdmin = await isAdminUser(user); const adminUrl = isAdmin ? adminEntryUrl() : null; + const compatibility = await isProductEnabled("compatibility"); return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, @@ -165,6 +167,7 @@ export async function GET() { longitude: profile.longitude ?? null, }, chartBirth: resolveServerOwnedChartBirth(profile), + compatibility, }); } catch (error) { if (isSupabaseConfigurationError(error)) { diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index b3f96161..8c924f4c 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -124,49 +124,22 @@ export async function GET(request: Request) { if (reportType !== "synastry_full" && subjectId !== "self" && !isChartSubjectId(subjectId)) { return NextResponse.json({ error: "星盘不存在", code: "subject_not_found" }, { status: 404 }); } - const listed = () => supabase + let listQuery = supabase .from("personal_reports") .select(REPORT_LIST_COLUMNS) .eq("user_id", user.id); - 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); - } + 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 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/lib/home-types.ts b/frontend/src/lib/home-types.ts index 156e9adf..992d2a17 100644 --- a/frontend/src/lib/home-types.ts +++ b/frontend/src/lib/home-types.ts @@ -140,6 +140,7 @@ export type Account = { hasUsableBirthTime: boolean; profile: unknown; chartBirth?: { fingerprint?: string | null } | null; + compatibility?: boolean; }; export type OnboardingStep = "name" | "birth" | "place" | "rectification"; export type AccountDialog = "profile" | "chart-library" | "billing" | "general" | "logout"; diff --git a/frontend/tests/personal-report-worker.test.ts b/frontend/tests/personal-report-worker.test.ts index 30e15e9a..95524f31 100644 --- a/frontend/tests/personal-report-worker.test.ts +++ b/frontend/tests/personal-report-worker.test.ts @@ -17,6 +17,7 @@ import { } from "../src/lib/personal-report-worker-core.ts"; import { loadReportWorkerProfile, reportWorkerUsesCandidateRange } from "../src/lib/personal-report-worker-subject.ts"; import type { ReportBillingPort } from "../src/lib/personal-report-billing.ts"; +import { writeSynastryLanguage } from "../src/lib/synastry-full-generate.ts"; const USER_ID = "11111111-1111-4111-8111-111111111111"; const REQUEST_ID = "22222222-2222-4222-8222-222222222222"; @@ -706,18 +707,50 @@ test("a synastry report rejected by the reader gate releases the reservation", a return true; }, }; + let gates = 0; const harness = createHarness({ billing, report: reportRecord({ reportType: "synastry_full" }), - generate: async () => ({ - status: "failed", - failureCode: "report_guard_rejected", - }), + generate: async () => { + try { + await writeSynastryLanguage({ + language: "zh", + names: { a: "甲", b: "乙" }, + dossier: {}, + segments: [ + { id: "s1", start: "2026-01", end: "2027-01", basis: "甲。" }, + { id: "s2", start: "2027-01", end: "2028-01", basis: "甲。" }, + { id: "s3", start: "2028-01", end: "2029-01", basis: "甲。" }, + ], + signal: new AbortController().signal, + timeoutMs: 1000, + write: async ({ 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: { text: "这段关系先把话说清楚。", reasons: [{ plain: "节奏不同。", basis: "证据甲", evidence: "证据甲", label: "盘面" }] }, inputTokens: 1, outputTokens: 1 }; + }, + gate: async () => { + gates += 1; + return { ok: false, failures: [{ section: "climate", code: "too_short" }] }; + }, + }); + } catch (error) { + assert.equal(gates, 2); + assert.equal(error instanceof Error ? error.message : "", "synastry_gate_rejected"); + throw new PersonalReportWorkerError("report_guard_rejected", false); + } + throw new Error("the second gate should have rejected the report"); + }, }); const result = await harness.worker.tick(); + assert.equal(gates, 2); assert.equal(result.outcome, "failed"); + assert.equal(harness.report?.status, "failed"); + assert.equal(harness.job.status, "failed"); assert.deepEqual(harness.billingReleaseReasons, ["report_guard_rejected"]); assert.equal(harness.billingCompleteCount, 0); }); diff --git a/frontend/tests/sidebar-contract.test.ts b/frontend/tests/sidebar-contract.test.ts index b20e80a7..0ee56da6 100644 --- a/frontend/tests/sidebar-contract.test.ts +++ b/frontend/tests/sidebar-contract.test.ts @@ -324,6 +324,9 @@ test("keeps app sidebar props as product data and callbacks", () => { assert.match(appSidebar, /compatibilityEnabled\?: boolean/); assert.match(appSidebar, /compatibilityEnabled = false/); assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i); + const appShell = readProjectFile("src/app/(app)/layout.tsx"); + assert.doesNotMatch(appShell, /\/api\/product-access/); + assert.match(appShell, /list\.account\?\.compatibility === true/); }); test("removes the user-facing admin entry and routes credit control to membership", () => { diff --git a/frontend/tests/subject-route-birth.test.ts b/frontend/tests/subject-route-birth.test.ts index 1456d596..1c596cd4 100644 --- a/frontend/tests/subject-route-birth.test.ts +++ b/frontend/tests/subject-route-birth.test.ts @@ -239,6 +239,14 @@ test("a missing report subject fails closed before billing", () => { if (column === "chart_profile_id" && value == null) subject = "self-null"; return q; }, + // 原值:这个替身没有 or(),列表恢复单次查询后会在这里抛错。 + // 新值:从 chart_profile_id.eq 读出人物,仍按下面的行过滤。 + // 原因:缺的是替身方法。断言仍是本人一行、对方一行、409、不扣点。 + or(expression) { + const matched = /chart_profile_id\.eq\.([^,]+)/.exec(String(expression)); + if (matched) subject = matched[1]; + return q; + }, order() { return q; }, limit() { return q; }, in() { return q; }, diff --git a/frontend/tests/synastry-full-english-job.test.ts b/frontend/tests/synastry-full-english-job.test.ts index d1d541d2..6e79924f 100644 --- a/frontend/tests/synastry-full-english-job.test.ts +++ b/frontend/tests/synastry-full-english-job.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { @@ -121,3 +123,132 @@ test("english usage goes through the same settlement call", async () => { assert.equal(payload.eventKey, "synastry.full"); assert.equal(payload.metadata?.kind, "synastry_full_english"); }); + +function runEnglish(fail: boolean): { id: string | null; events: Array<{ table?: string; op: string; values?: Record; row?: Record; name?: string }> } { + const frontendRoot = fileURLToPath(new URL("../", import.meta.url)); + const script = ` + import { mock } from "node:test"; + import { pathToFileURL } from "node:url"; + const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; + const fail = ${fail ? "true" : "false"}; + mock.module("server-only", { namedExports: {} }); + mock.module("@/lib/model-catalog", { namedExports: { + loadLanguageModelCatalog: async () => ({ + defaultModelId: "model-a", + models: [{ id: "model-a", configVersion: 1, inputCostMicrousdPerMillion: 0, outputCostMicrousdPerMillion: 0 }], + }), + } }); + mock.module("@/mastra/synastry-report", { namedExports: { + createSynastrySectionWriter() { + return async ({ sectionId }) => { + if (fail) throw new Error("model_down"); + if (sectionId === "timeline") { + return { body: { segments: [ + { id: "s1", phase: "settle", text: "Name the pace." }, + { id: "s2", phase: "repair", text: "Finish the disagreement." }, + { id: "s3", phase: "build", text: "Write the next step." }, + ] }, inputTokens: 2, outputTokens: 3 }; + } + if (sectionId === "practices") { + return { body: { items: [{ text: "Talk once a week.", evidence: "shared note" }] }, inputTokens: 1, outputTokens: 1 }; + } + return { body: { text: "This pace needs a sentence.", reasons: [{ plain: "The pace differs.", basis: "note", evidence: "note", label: "chart" }] }, inputTokens: 1, outputTokens: 1 }; + }; + }, + } }); + globalThis.fetch = async () => Response.json({ ok: true, failures: [] }); + const events = []; + const snapshot = { + dossier: {}, + names: { a: "Albert", b: "Steve" }, + timingPeriods: { + person_a: [{ level: "md", lord: "jupiter", start: "2020-01-01", end: "2036-01-01" }], + person_b: [{ level: "md", lord: "venus", start: "2020-01-01", end: "2036-01-01" }], + }, + }; + function query(result) { + const builder = { + eq() { return builder; }, + in() { return builder; }, + lt() { return builder; }, + limit() { return builder; }, + maybeSingle: async () => result(), + then(resolve, reject) { return Promise.resolve(result()).then(resolve, reject); }, + }; + return builder; + } + const admin = { + from(table) { + return { + select() { + return query(() => { + if (table === "synastry_english_jobs") { + return { data: { report_id: "r1", user_id: "u1", attempt_count: 0, max_attempts: 2, status: "queued" }, error: null }; + } + if (table === "personal_reports") { + return { data: { id: "r1", user_id: "u1", request_id: "req-1", report_type: "synastry_full", status: "ready" }, error: null }; + } + if (table === "personal_report_sections") { + return { data: { payload: snapshot }, error: null }; + } + return { data: null, error: null }; + }); + }, + update(values) { + events.push({ table, op: "update", values }); + return query(() => ({ data: null, error: null })); + }, + insert(row) { + events.push({ table, op: "insert", row }); + return Promise.resolve({ error: null }); + }, + upsert(row) { + events.push({ table, op: "upsert", row }); + return Promise.resolve({ error: null }); + }, + }; + }, + rpc(name, args) { + events.push({ op: "rpc", name, args }); + return Promise.resolve({ data: { success: true, reservation_id: null, credits: 0, error_code: null }, error: null }); + }, + }; + const { runIdleSynastryEnglish } = await import(moduleUrl("src/lib/synastry-full-run.ts")); + const id = await runIdleSynastryEnglish(admin, new Date("2026-10-19T00:00:00Z")); + console.log(JSON.stringify({ id, events })); + `; + 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 { id: string | null; events: Array<{ table?: string; op: string; values?: Record; row?: Record; name?: string }> }; +} + +test("an english job claims, writes the snapshot, marks ready, and records usage", () => { + const result = runEnglish(false); + assert.equal(result.id, "r1"); + const ready = result.events.filter((event) => event.table === "synastry_english_jobs" && event.op === "update"); + assert.equal(ready.at(-1)?.values?.status, "ready"); + const saved = result.events.find((event) => event.op === "upsert"); + assert.equal(saved?.table, "personal_report_sections"); + const payload = saved?.row?.payload as { english?: unknown } | undefined; + assert.ok(payload?.english); + assert.ok(result.events.some((event) => event.table === "usage_reservations" && event.op === "insert")); + assert.ok(result.events.some((event) => event.op === "rpc" && event.name === "complete_usage")); +}); + +test("a thrown english generation marks the job failed and does not leave it running", () => { + const result = runEnglish(true); + assert.equal(result.id, null); + const updates = result.events.filter((event) => event.table === "synastry_english_jobs" && event.op === "update"); + assert.equal(updates.at(-1)?.values?.status, "failed"); + assert.equal(updates.at(-1)?.values?.last_error_code, "model_unavailable"); + assert.equal(updates.at(-1)?.values?.status === "running", false); + assert.equal(result.events.some((event) => event.op === "upsert"), false); +}); diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 243f2e8f..10d96cde 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -9,7 +9,7 @@ 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, 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 { 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; @@ -342,12 +342,55 @@ test("a public pair dossier fills every section without birth keys", { timeout: 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), "外部资料与真实案例核对:未进行,本报告是计算层归纳"); + const receipt = dossier.execution_receipt as { + individual_technique_audit?: Array>; + pair_execution_tasks?: Array>; + }; + const statusText: Record = { + executed: "已执行", + ready_for_comparison: "已执行", + present: "已执行", + used: "已执行", + available: "已执行", + passed: "已执行", + parameter_sensitive: "参数敏感", + partial: "参数敏感", + weak: "参数敏感", + observation_only: "参数敏感", + not_applicable: "不适用", + not_used: "不适用", + not_used_for_date_intersection: "不适用", + }; + const receiptRows = [...(receipt.individual_technique_audit ?? []), ...(receipt.pair_execution_tasks ?? [])]; + const executionLines = blocks[5]?.lines ?? []; + let namedRows = 0; + for (const row of receiptRows) { + const name = String(row.technique || row.task || row.label || row.lane || "").trim(); + const status = String(row.status || "").trim(); + if (!name || !status) continue; + const shown = statusText[status.toLowerCase()] ?? status; + assert.equal(executionLines.some((line) => line.includes(name) && line.includes(shown)), true, `${name} ${shown}`); + namedRows += 1; + } + assert.ok(namedRows > 0); + assert.equal(executionLines.at(-1), "外部资料与真实案例核对:未进行,本报告是计算层归纳"); + const structures = dossier.local_structures as Record>; + for (const person of ["person_a", "person_b"]) { + const local = structures[person] ?? {}; + const d9 = local.d9 as { ascendant?: unknown } | undefined; + for (const [key, value] of [ + ["seventh_lord", local.seventh_lord], + ["darakaraka", local.darakaraka], + ["upapada", local.upapada], + ["A7", local.A7], + ["d9", d9?.ascendant], + ] as const) { + assert.equal(typeof value, "string", `${person} ${key}`); + assert.ok(String(value).length > 0, `${person} ${key}`); + } + } for (const line of blocks[1]?.lines ?? []) { - assert.equal(/上升 未标明;金星 未标明;月亮 未标明/.test(line), false, line); + assert.equal(/7宫主 未标明|DK 未标明|UL 未标明|A7 未标明|D9 未标明/.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 bd7391f9..f49d15d0 100644 --- a/scripts/marriage_match_service/full_dossier.py +++ b/scripts/marriage_match_service/full_dossier.py @@ -151,19 +151,24 @@ def relationship_structure(reading: dict[str, Any]) -> dict[str, Any]: 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")) + _named_planet(jaimini, ("chara_karakas", "DK")) + or _named_planet(jaimini, ("chara_karakas", "Darakaraka")) or _named_planet(jaimini, ("chara_karaka_7", "karaka_table", "Darakaraka")) + or _named_planet(jaimini, ("chara_karaka_8", "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 {} + if not special and isinstance(jaimini.get("special_lagnas"), dict): + special = jaimini.get("special_lagnas") + arudha = jaimini.get("arudha_padas") if isinstance(jaimini.get("arudha_padas"), dict) else {} + if not arudha and isinstance(modules.get("arudha_padas"), dict): + arudha = modules.get("arudha_padas") padas = arudha.get("padas") if isinstance(arudha.get("padas"), dict) else arudha - upapada = _sign_name(special.get("Upapada_Lagna") or special.get("UL")) + upapada = _sign_name(special.get("Upapada_Lagna") or special.get("UL") or arudha.get("upapada")) 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")) + a7 = _sign_name(padas.get("A7") if isinstance(padas, dict) else None) + if a7 is None: + a7 = _sign_name(special.get("A7") or special.get("Darapada") or arudha.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")) diff --git a/tests/test_synastry_full_dossier.py b/tests/test_synastry_full_dossier.py index dced815d..c6ec5bec 100644 --- a/tests/test_synastry_full_dossier.py +++ b/tests/test_synastry_full_dossier.py @@ -12,7 +12,7 @@ if str(SCRIPTS) not in sys.path: sys.path.insert(0, str(SCRIPTS)) from jyotish_api_server import JyotishComputeService -from marriage_match_service.full_dossier import build_synastry_full_dossier +from marriage_match_service.full_dossier import build_synastry_full_dossier, relationship_structure FIXTURE = ROOT / "frontend" / "tests" / "fixtures" / "synastry-full-dossier-truncated.json" @@ -53,6 +53,30 @@ def truncate_dossier(packet: dict) -> dict: } +def test_relationship_structure_reads_jaimini_arudha_and_karaka(): + structure = relationship_structure({ + "modules": { + "chart": { + "ascendant": {"sign": "Aries"}, + "planets": {"Venus": {"sign": "Taurus"}, "Moon": {"sign": "Cancer"}}, + }, + "jaimini": { + "chara_karakas": {"DK": {"planet": "Venus"}}, + "arudha_padas": { + "upapada": {"sign": "Libra"}, + "padas": {"A7": {"sign": "Scorpio"}, "UL": {"sign": "Libra"}}, + }, + }, + "varga_full": {"D9_Navamsa": {"Ascendant": {"sign": "Pisces"}}}, + }, + }) + assert structure["seventh_lord"] == "Venus" + assert structure["darakaraka"] == "Venus" + assert structure["upapada"] == "Libra" + assert structure["A7"] == "Scorpio" + assert structure["d9"]["ascendant"] == "Pisces" + + def test_public_pair_dossier_is_ready_for_narrative(): handler = JyotishComputeService() packet = build_synastry_full_dossier(handler, EINSTEIN, JOBS)