fix(synastry): restore report paging and finish the third review gaps
The list query had been split into four reads, so older pages no longer followed one cursor. A7 was read from the wrong module, and the shell asked for the product switch on every mount. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
88d641974e
commit
123295135a
@@ -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 年。只列主运,子运暂不列出。两宫分不出强弱时,这张表本轮不列出。合盘仍不用它做应期。
|
||||
|
||||
+23
-6
@@ -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`。
|
||||
|
||||
|
||||
@@ -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` 本轮没有再跑。
|
||||
|
||||
@@ -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 (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 = <T extends { order(column: string, options: { ascending: boolean }): T; limit(count: number): T; or(filter: string): T }>(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<string, { created_at?: unknown; id?: unknown }>();
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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<string, unknown>; row?: Record<string, unknown>; 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<string, unknown>; row?: Record<string, unknown>; 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);
|
||||
});
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
pair_execution_tasks?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const statusText: Record<string, string> = {
|
||||
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<string, Record<string, unknown>>;
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user