diff --git a/CHANGELOG.md b/CHANGELOG.md index 1856fe59..8ef4973b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-21 — 合盘两人顺序跟选人页一致 + +- 先选的人写在前面。等待页、报告标题、报告中心卡片和追问对话标题用同一个顺序。同一对已经有报告时,仍打开原来那份。Skill 版本不变(BUG-1407)。 + ## 2026-10-21 — 合盘正文不再重复节标题 - 某一节的正文如果以这一节的标题开头,质检会要求重写这一节。保存前也会把开头重复的标题去掉。中文和英文都是这样。Skill 版本不变(BUG-1406)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dd77f016..bb5908da 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -37063,3 +37063,19 @@ - 相关记录:issue #85。 - 复发自:无。 - 修复版本:分支 `codex/synastry-staging-fix-20261021`。 + +## BUG-1407 | 合盘两人顺序和选人页相反 + +- 状态:resolved(待 staging 真机复测) +- 首次发现:2026-10-21(产品负责人真机) +- 最近更新:2026-10-21 +- 影响面:合盘等待页、报告标题、报告中心卡片、追问对话标题 +- 用户现象:选人页先选甲、再选乙。等待页是甲 × 乙。写完之后,报告标题、报告中心卡片和追问对话标题变成乙 × 甲。 +- 触发条件:先选的那个人,编号排在后选的那个人后面。同一对已经有一份报告时,仍打开原来那份,顺序是当时保存的顺序。 +- 根因:去重用的一对编号按编号从小到大存放。生成时把较小的编号当成甲、较大的编号当成乙。等待页读的是选人页当场记下的顺序,所以只有等待页是对的。 +- 修复:创建时另存选人顺序(先选的人、后选的人)。生成按这个顺序加载两个人,报告标题、卡片摘要和追问标题都用这个顺序。去重用的编号排序不变。没有这份顺序的旧报告仍按原来的编号顺序。等待页本来就是选人顺序,没有改。 +- 验证:`frontend/tests/synastry-display-order.test.ts`:先选的人编号更大时,标题和追问标题仍是甲 × 乙;等待页句子是甲在前。`frontend/tests/database-synastry-full-create.test.ts`:请求先是本人、后是另一人时,存下的顺序是本人在前,而去重编号仍是另一人在前。 +- 防复发:上述两条。生成不要再把较小的编号直接当成甲。 +- 相关记录:issue #85。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-staging-fix-20261021`。 diff --git a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md index d966b400..afb065d3 100644 --- a/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md +++ b/docs/tasks/PROGRESS-synastry-staging-fix-20261021.md @@ -59,8 +59,30 @@ - 合盘揭幕后的打印样式加上 `media="print"`,平时侧栏还在。 - 失败态带上 `reportType`。个人报告失败断言的原值是没有这个字段,新值带上 `personal_full`,因为合盘地址要能认出个人报告。 - 真机(执行方未做,不得写成已通过):点「开始合盘」后,侧栏当前项仍是「婚恋合盘」。 +- 左上角回到选人页仍是「‹ 婚恋合盘」。新路由让页面链接规则不接受普通 ``,所以改成 `Link`,目的地不变。 ## T9 重复标题(BUG-1406) - 正文以该节标题开头时,质检返回 `repeated_heading`,只重写这一节。通过后保存前再剥掉开头的标题。中文和英文同一条规则。「沟通与理解力」这种紧挨着的字不剥。 - 真机(执行方未做,不得写成已通过):打开一份新写的合盘,各节标题下面的第一句不要再重复标题。 + +## T10 两人顺序(BUG-1407) + +- 根因:去重把两个人按编号从小到大存放,生成又把较小的编号当成甲。等待页用的是选人页当场记下的顺序,所以只有等待页没反。 +- 创建时把选人顺序另存一节。生成按这个顺序加载两个人,标题、卡片和追问标题跟着走。去重用的编号排序不动。同一对已经有报告时仍打开原来那份,顺序是当时保存的。 +- 真机(执行方未做,不得写成已通过):先选甲、再选乙。等待页、揭幕后的标题、报告中心卡片、从报告进的对话标题都是甲 × 乙。 + +## 这一轮检查 + +| 检查 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| 定向:滚动、重复标题、地址、顺序、报告渲染 | 通过,0 cancelled。失败态断言原值没有 `reportType`,新值带上 `personal_full` | +| 质检 `tests/test_synastry_full_reader_gate.py` | 通过。正文以节标题开头判失败 | +| 合盘创建真库(单独) | 通过。请求先是本人、后是另一人;顺序节是本人在前,去重编号是另一人在前 | +| `npm run build` | `/` 与 `/synastry` 为 Static。`/synastry/[reportId]` 为 Dynamic。首屏引用的 js/css gzip -9:JS 577,319,CSS 70,518,合计 647,837。对照 643,725,差 +0.64%,在 ±2% 内 | +| 全量 `eslint` | 0 error,122 warning,与基线相同。中途有过 1 个 error:新路由不接受 ``。返回链接改成 `Link` 后消失 | + +全量 Python 快速门没有跑:这个工作树没有项目虚拟环境。质检定向测试用系统 Python 跑过。 + +未推 staging,未改 `.gitea/`。真机未做,上面的真机步骤都不得写成已通过。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 63900fb5..0da3bc84 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -509,5 +509,5 @@ | `TASK-niryaana-shoola-pvr-20261018.md` | `PROGRESS-niryaana-shoola-pvr-20261018.md` | #84 Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 待验收 | `codex/niryaana-shoola-pvr-20261018` | | `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 | | `TASK-synastry-report-chat-20261018.md` | `PROGRESS-synastry-report-chat-20261018.md` | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 已验收 | `549e37b99`;动表 | -| `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:P0 `.gte` 已在 staging(BUG-1399)。本分支补时间线起止、英文分析逻辑、删模型后旧对话改用默认模型、选人页开关文案,以及合盘创建真库测试(BUG-1400~1403) | 待验收 | `codex/synastry-staging-fix-20261021` | +| `TASK-synastry-staging-fix-20261021.md` | `PROGRESS-synastry-staging-fix-20261021.md` | #85 婚恋合盘 staging:T3~T6 已在 staging `ff6b228dd`(BUG-1400~1403)。本分支继续报告滚动、独立地址、重复标题、两人顺序(BUG-1404~1407) | 待验收 | `codex/synastry-staging-fix-20261021` | | `TASK-synastry-report-chat-fix-20261020.md` | `PROGRESS-synastry-report-chat-20261018.md` | 大运核对要认报告里的「小运」和英文 mahadasha / antardasha;预填文案只留一句 | 已验收 | `549e37b99` | diff --git a/frontend/src/components/synastry/synastry-report-view.tsx b/frontend/src/components/synastry/synastry-report-view.tsx index 167f1b8c..f17c9217 100644 --- a/frontend/src/components/synastry/synastry-report-view.tsx +++ b/frontend/src/components/synastry/synastry-report-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import Link from "next/link"; import { SynastryWait } from "@/components/synastry/synastry-wait"; import { beginSynastrySectionChat } from "@/lib/synastry-report-chat-client"; @@ -131,7 +132,7 @@ export function SynastryReportView(props: { return (
- ‹ 婚恋合盘 + ‹ 婚恋合盘

{title}

diff --git a/frontend/src/lib/synastry-display-order.ts b/frontend/src/lib/synastry-display-order.ts new file mode 100644 index 00000000..07b5b916 --- /dev/null +++ b/frontend/src/lib/synastry-display-order.ts @@ -0,0 +1,15 @@ +/** Selection order for a synastry report. The pair key stays sorted for dedup. */ + +export const SYNASTRY_ORDER_SECTION = "synastry_order"; + +export function synastryDisplaySubjectIds(low: string, high: string, order: unknown): [string, string] { + const row = order && typeof order === "object" && !Array.isArray(order) + ? order as Record + : null; + const first = typeof row?.first === "string" ? row.first : ""; + const second = typeof row?.second === "string" ? row.second : ""; + if (first !== second && (first === low || first === high) && (second === low || second === high)) { + return [first, second]; + } + return [low, high]; +} diff --git a/frontend/src/lib/synastry-full-create.ts b/frontend/src/lib/synastry-full-create.ts index 9526927a..28ecfdf8 100644 --- a/frontend/src/lib/synastry-full-create.ts +++ b/frontend/src/lib/synastry-full-create.ts @@ -5,6 +5,7 @@ import type { DataQueryBuilder } from "@/lib/db/data-client"; import { consultationSubjectFailureResponse } from "@/lib/consultation-subject-resolver"; import { FeaturePricingError, resolveFeaturePricing } from "@/lib/feature-pricing"; import { MARRIAGE_MATCH_RULES_VERSION, marriageMatchFingerprint } from "@/lib/marriage-match-service"; +import { SYNASTRY_ORDER_SECTION } from "@/lib/synastry-display-order"; import { loadLanguageModelCatalog } from "@/lib/model-catalog"; import { REPORT_STABLE_CODES } from "@/lib/personal-report-codes"; import { readPersonalReportDailyLimit } from "@/lib/personal-report-entitlement"; @@ -215,6 +216,24 @@ export async function createSynastryFullReport(input: Readonly<{ await release(REPORT_STABLE_CODES.generationInProgress); return { status: 409, body: { error: "已有报告正在生成中", code: REPORT_STABLE_CODES.generationInProgress } }; } + const orderSaved = await (input.admin as { + from(table: string): { + insert(row: Record): Promise<{ error: { message?: string } | null }>; + }; + }).from("personal_report_sections").insert({ + user_id: input.userId, + request_id: requestId, + section_id: SYNASTRY_ORDER_SECTION, + status: "ready", + payload: { first: subjectA, second: subjectB }, + attempt_count: 1, + max_attempts: 1, + }); + if (orderSaved.error) { + await persistence.markFailed(input.userId, created.record.id, REPORT_STABLE_CODES.generationFailed); + await release(REPORT_STABLE_CODES.generationFailed); + return { status: 503, body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed } }; + } try { const enqueued = await createSupabasePersonalReportJobService(input.admin as never).enqueue({ userId: input.userId, diff --git a/frontend/src/lib/synastry-full-run.ts b/frontend/src/lib/synastry-full-run.ts index 3262a35c..c047b8d2 100644 --- a/frontend/src/lib/synastry-full-run.ts +++ b/frontend/src/lib/synastry-full-run.ts @@ -14,6 +14,7 @@ import { PersonalReportWorkerError } from "@/lib/personal-report-worker-core"; import type { PersonalReportRecord } from "@/lib/personal-report-service-core"; import { loadSubjectBirth } from "@/lib/subject-birth"; import { marriageMatchBirthBody } from "@/lib/marriage-match-service"; +import { SYNASTRY_ORDER_SECTION, synastryDisplaySubjectIds } from "@/lib/synastry-display-order"; import { timelineForPair, writeSynastryLanguage, type SynastrySnapshot } from "@/lib/synastry-full-generate"; import type { TimingPeriod } from "@/lib/synastry-full-timeline"; @@ -160,9 +161,21 @@ export async function runSynastryFullReport(input: Readonly<{ } await input.onProgress?.({ phase: "computing_charts", completed: 0, total: 9 }); const today = new Date().toISOString().slice(0, 10); + const orderRow = await input.admin.from("personal_report_sections") + .select("payload") + .eq("user_id", input.report.userId) + .eq("request_id", input.report.requestId) + .eq("section_id", SYNASTRY_ORDER_SECTION) + .maybeSingle(); + const orderPayload = orderRow.error ? null : asRecord(orderRow.data)?.payload ?? null; + const [firstId, secondId] = synastryDisplaySubjectIds( + input.report.subjectLow, + input.report.subjectHigh, + orderPayload, + ); const [personA, personB] = await Promise.all([ - loadSubjectBirth(input.admin, input.report.userId, input.report.subjectLow), - loadSubjectBirth(input.admin, input.report.userId, input.report.subjectHigh), + loadSubjectBirth(input.admin, input.report.userId, firstId), + loadSubjectBirth(input.admin, input.report.userId, secondId), ]); const packet = asRecord(await postJson("/api/synastry_full_dossier", { person_a: marriageMatchBirthBody(personA.row, today), diff --git a/frontend/tests/database-synastry-full-create.test.ts b/frontend/tests/database-synastry-full-create.test.ts index 73ae711a..5ac41858 100644 --- a/frontend/tests/database-synastry-full-create.test.ts +++ b/frontend/tests/database-synastry-full-create.test.ts @@ -123,6 +123,10 @@ test("createSynastryFullReport reserves synastry.full and enqueues a job on the assert.equal(fixture.psql(`select status from public.personal_report_jobs where user_id = '${userId}'`), "queued"); assert.equal(fixture.psql(`select status from public.usage_reservations where user_id = '${userId}'`), "reserved"); assert.equal(fixture.psql(`select credits from public.profiles where id = '${userId}'`), "81"); + assert.equal(fixture.psql(`select payload->>'first' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), "self"); + assert.equal(fixture.psql(`select payload->>'second' from public.personal_report_sections where user_id = '${userId}' and section_id = 'synastry_order'`), personId); + assert.equal(fixture.psql(`select subject_low from public.personal_reports where id = '${report?.id}'`), personId); + assert.equal(fixture.psql(`select subject_high from public.personal_reports where id = '${report?.id}'`), "self"); catalog.setModelCatalogIoForTests(null); catalog.invalidateLanguageModelCatalog(); } finally { diff --git a/frontend/tests/synastry-display-order.test.ts b/frontend/tests/synastry-display-order.test.ts new file mode 100644 index 00000000..3a246691 --- /dev/null +++ b/frontend/tests/synastry-display-order.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; + +import { synastryDisplaySubjectIds } from "../src/lib/synastry-display-order.ts"; +import { synastryFollowupTitle } from "../src/lib/synastry-report-session.ts"; + +test("picker order outranks the sorted pair key for the title and the chat", () => { + const ids = synastryDisplaySubjectIds("person-yi", "self", { first: "self", second: "person-yi" }); + assert.deepEqual(ids, ["self", "person-yi"]); + const labels: Record = { self: "甲", "person-yi": "乙" }; + const names = { a: labels[ids[0]] ?? "", b: labels[ids[1]] ?? "" }; + assert.equal(`${names.a} × ${names.b} · 婚恋合盘`, "甲 × 乙 · 婚恋合盘"); + assert.equal(synastryFollowupTitle(names), "甲 × 乙 · 合盘追问"); + assert.deepEqual(synastryDisplaySubjectIds("person-yi", "self", null), ["person-yi", "self"]); + const wait = readFileSync(new URL("../src/components/synastry/synastry-wait.tsx", import.meta.url), "utf8"); + const run = readFileSync(new URL("../src/lib/synastry-full-run.ts", import.meta.url), "utf8"); + assert.match(wait, /\$\{a\} × \$\{b\}/); + assert.match(run, /synastryDisplaySubjectIds\(/); + assert.match(run, /cardSummary: `\$\{names\.a\} × \$\{names\.b\} · 婚恋合盘`/); +});