Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12684c8b8f | ||
|
|
bcd1744696 |
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-15 — 合盘写作核对认同一盘面事实的不同写法
|
||||
|
||||
- 判断依据与证据比对时,宫位写法、中英文星体和星座名视为同一事实;修订时告诉写作具体哪个词没对上;合盘生成失败页标题改为「婚恋合盘」。占星 Skill 版本不变(BUG-1897)。
|
||||
|
||||
## 2026-11-15 — 合盘分开显示正式核对和研究扫描
|
||||
|
||||
- 报告和追问分别展示五类事件的正式核对、研究日期和冲突说明。附录列出两人的运限条件、相关点位及法律婚姻缺项。旧报告重新打开时使用已保存的依据。占星 Skill 版本不变(BUG-1895)。
|
||||
|
||||
@@ -25601,3 +25601,17 @@
|
||||
- 验证与防复发:`chat-composer-single-row-20261103` 新增手机用例,覆盖空输入和短句都保持两层、桌面不受影响,以及下面一行的网格区域。
|
||||
- 关联记录:BUG-1795。
|
||||
- 修复版本:`fix/plain-mode-phone-composer-1115`,待 staging 验收。
|
||||
|
||||
## BUG-1897 | 合盘写作核对把格式不同的同一盘面事实判为不一致,真实合盘无法生成
|
||||
|
||||
- 状态:fixed-pending-verify(真实模型复测待产品负责人批准费用)。
|
||||
- 首次发现 / 最近更新:2026-11-15 / 2026-11-15。
|
||||
- 触发与现象:staging 真实账号发起婚恋合盘,等待数分钟后显示「报告生成失败」,错误码 `report_guard_rejected`,顶栏误写「个人报告」。本地用公开名人盘实写三次(十节中英、九节中文)均在两轮核对后被拒。生产 main 尚无这套核对,不受影响。
|
||||
- 根因:
|
||||
- `synastry_full_reader_gate.py` 要求 basis 中的星体、星座、宫位、分盘按字面出现在 evidence。程序证据中英混排、宫位写作「第 9 宫」,模型写「第9宫」「木星」「水瓶」即判 `basis_mismatch`,这是失败最多的一类。
|
||||
- #281 十节提示词没有讲清 evidence 只放一条,模型把数组或对象粘进 evidence(`evidence_missing`)。
|
||||
- 修订轮只把失败代码传给模型,不传失败的词,第二轮几乎不改善。
|
||||
- 修复:basis 与 evidence 按规范键比对(宫位号、中英文星体/星座/星宿名、`D数字`);D1 即本命盘,不单独比对;其余词仍须出现,检查未删除。十节提示词写明 evidence 只放一条完整原文、basis 只提该条里有的盘面事实。修订轮传「代码: 细节」(截断 80 字、每节至多 20 条)并附每个代码的固定改法。合盘失败页顶栏改为「婚恋合盘」。
|
||||
- 验证与防复发:`tests/test_synastry_gate_repair_20261115.py`(混排证据通过、错宫错星仍拒、中英互认、D1、星宿、数组仍拒);`frontend/tests/synastry-gate-repair-20261115.test.ts`(修订细节、截断上限、改法说明、提示词、失败页标题)。
|
||||
- 关联记录:#281、#289、BUG-1892、BUG-1895。
|
||||
- 修复版本:`claude/synastry-gate-repair-20261115`。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 合盘写作核对拒稿修复执行记录(2026-11-15)
|
||||
|
||||
- 工单 #291,BUG-1897。产品负责人改为由 Claude(jyotisha-8a)直接修复,分支 `claude/synastry-gate-repair-20261115`,基线 6a3f3b40(任务书 30a479f6)。
|
||||
|
||||
## 规范化规则
|
||||
|
||||
| 类别 | 认作同一事实的写法 | 规范键 |
|
||||
| --- | --- | --- |
|
||||
| 宫位 | 第 9 宫、第9宫、9宫、第九宫、9th house、house 9、H9 | `h:9` |
|
||||
| 星体 | 木星 / Jupiter(大小写不计) | `p:jupiter` |
|
||||
| 星座 | 水瓶 / Aquarius | `s:aquarius` |
|
||||
| 星宿 | 27 宿与 nakshatra 标准对照(牛宿无对应);Purva/Uttara 合为一名,与原英文表一致 | `n:…` |
|
||||
| 分盘 | D9、d9、D-9 | `d:9` |
|
||||
| D1 | 本命盘本身,程序证据不写 D1 | 不比对 |
|
||||
| 大运、小运、dasha | 字面 | `dasha:…` |
|
||||
|
||||
规范化后不在证据里的词照旧记 `basis_mismatch`。evidence 仍须是完整字符串字段,数组或对象照旧记 `evidence_missing`。
|
||||
|
||||
## 与任务书的差异
|
||||
|
||||
- 失败页顶栏用页面已有的合盘标题「婚恋合盘」(同文件 453 行),没有新造「合盘报告」。
|
||||
- D1 不比对:三次实写中 D1 是 basis_mismatch 的常见词(英文 15 次),而证据只写宫位和星体、不写 D1。
|
||||
|
||||
## 验证
|
||||
|
||||
- 新增 Python 7 项、前端 4 项通过;合盘既有前端套件(#281、#289、完整报告)通过;`tsc --noEmit` 通过。
|
||||
- 真实模型复测:未做,等待产品负责人批准 DeepSeek 费用(公开名人盘一对,中英各一次)。
|
||||
@@ -0,0 +1,95 @@
|
||||
# 合盘写作核对拒稿修复(2026-11-15)
|
||||
|
||||
- 工单:见 Gitea issue。BUG-1897。执行:Codex。负责验收:Claude(jyotisha-8a)。
|
||||
- 分支 `codex/synastry-gate-repair-20261115`,基线 staging `6a3f3b4012b9f293a868600447bc33f7e3388e31`。
|
||||
- 关联:#281(BUG-1892,十节)、#289(BUG-1895,应期)、BUG-1888。
|
||||
|
||||
## 0. 现象
|
||||
|
||||
2026-11-15 产品负责人在 staging(6a3f3b40)用真实账号发起婚恋合盘,等待数分钟后页面显示「报告生成失败 / 本次生成没有产出可用报告 / 错误码 report_guard_rejected」。错误页顶栏写的是「个人报告」。
|
||||
|
||||
生产环境 main 尚无这套合盘核对,不受影响。
|
||||
|
||||
## 1. 已确认的原因
|
||||
|
||||
Claude 在本地用一对公开名人盘、deepseek-v4-pro 真实写作两次(#281 十节中英文各一次、#281 之前的九节中文一次)。三次都在两轮核对后被拒,原始失败已存档,不入库。失败分布:
|
||||
|
||||
| 运行 | 第 1 轮 | 第 2 轮(修订后) |
|
||||
| --- | --- | --- |
|
||||
| 十节中文 | basis_mismatch 67、evidence_missing 21、unexplained_term 6、banned_judgement 3、score 2 | basis_mismatch 42、evidence_missing 23、unexplained_term 6、banned_judgement 5 |
|
||||
| 十节英文 | basis_mismatch 34、evidence_missing 21、unexplained_term 5、banned_judgement 1 | evidence_missing 24、basis_mismatch 13、unexplained_term 6 |
|
||||
| 九节中文(#281 前) | basis_mismatch 28、score 15、unexplained_term 3、banned_judgement 1 | basis_mismatch 31、score 12、unexplained_term 1、banned_judgement 1 |
|
||||
|
||||
### 1.1 basis_mismatch 多数是核对本身的毛病
|
||||
|
||||
`synastry_full_reader_gate.py` 的 `_basis_tokens` 从 `basis` 中抽出星体、星座、星宿、宫位、`D数字`,再要求每个词按字面出现在 `evidence` 里。但证据原文是程序生成的中英混排,例如:
|
||||
|
||||
> 7宫主 Jupiter 落第 9 宫 Aquarius(入友,功能凶星);金星 落第 10 宫 Pisces……
|
||||
|
||||
模型在 `basis` 里写「第9宫」「木星」「水瓶」时,证据里分别是「第 9 宫」「Jupiter」「Aquarius」,全部判不一致。抽出的 detail 前几条全是「第9宫」「第10宫」「第3宫」。
|
||||
|
||||
### 1.2 evidence_missing 是 #281 带来的回退
|
||||
|
||||
`evidence` 必须是该节输入里某个完整字符串字段(`_string_values`)。十节版里模型把整个数组或对象 JSON 粘进 `evidence`(如 `["甲 关系承诺:……", …]`、`{"event_class": …}`),每节约 2–3 条。九节版没有这一类。
|
||||
|
||||
### 1.3 修订轮拿不到失败细节
|
||||
|
||||
`synastry-full-generate.ts` 修订时只把失败的 `code` 传给写作(`.map(({ code }) => code)`),不传 `detail`。模型只知道「basis_mismatch」,不知道是哪个词、哪条证据,所以第 2 轮几乎没改善。
|
||||
|
||||
### 1.4 其余
|
||||
|
||||
- unexplained_term:D1、D9、DK 在正文里没有解释。
|
||||
- banned_judgement:「凶」「不宜」「必然」。
|
||||
- score:九节版正文写了「数字分」;十节版已基本消失。
|
||||
|
||||
## 2. 要做的
|
||||
|
||||
### 2.1 basis 比对改成规范化比对(Python)
|
||||
|
||||
- 新增一个规范化函数,同时作用于抽出的词和 `evidence`:
|
||||
- 宫位:「第 9 宫」「第9宫」「9宫」「第九宫」都归为同一个宫位号;英文 `9th house`、`house 9` 同理。
|
||||
- 星体、星座、星宿:中英文名对照到同一个规范名(复用本文件已有的 ZH_PLANETS/EN_PLANETS、ZH_SIGNS/EN_SIGNS、ZH_NAKSHATRAS/EN_NAKSHATRAS 对照;若没有一一对应表,就在本文件建一张,来源写清)。
|
||||
- `D9`/`d9`、全角半角、多余空白统一。
|
||||
- 规范化后仍不在证据里的词,照旧记 `basis_mismatch`。**不得**删除这项检查,也不得改成「出现任意一个词就算过」。
|
||||
- 英文核对同样处理(证据中文、basis 英文的情况)。
|
||||
|
||||
### 2.2 evidence 只能填一条原文(提示词 + 修订提示)
|
||||
|
||||
- 十节写作提示(V3_SYSTEM 及每节任务)明确写:`evidence` 只复制输入里**一条**原文字符串,不得粘数组、对象或拼接多条;需要多条证据就分成多条分析。
|
||||
- 核对规则保持严格,`evidence` 仍须是完整字符串字段。**不**放宽为接受数组。
|
||||
|
||||
### 2.3 修订轮带上失败细节
|
||||
|
||||
- 修订时传 `code` 加 `detail`(例如 `basis_mismatch: 第9宫`、`evidence_missing: <前 80 字>`、`unexplained_term: D9`、`banned_judgement: 不宜`),并配一句中文/英文的改法说明(每个 code 一句,固定文案,放在写作模块常量里)。
|
||||
- 细节截断,单节不超过 20 条,避免提示过长。
|
||||
|
||||
### 2.4 错误页顶栏
|
||||
|
||||
合盘生成失败时,错误页顶栏写「合盘报告」(英文 Synastry report),不写「个人报告」。只改这一处标题,不改错误页其余文字和布局。
|
||||
|
||||
### 2.5 不做
|
||||
|
||||
- 不改 unexplained_term、banned_judgement、score 的规则本身(这些是她的写作约束)。
|
||||
- 不改十节结构、应期三行、Raman、出生时间误差附录。
|
||||
- 不调用 DeepSeek 或任何付费模型。真实写作验证由 Claude 在验收时做。
|
||||
|
||||
## 3. 测试
|
||||
|
||||
- Python:用 1.1 的真实混排证据句造合成用例:
|
||||
- basis「第9宫」「木星」「水瓶」对证据「第 9 宫 Aquarius / Jupiter」→ 通过;
|
||||
- basis「第7宫」对只含「第 9 宫」的证据 → 仍 `basis_mismatch`;
|
||||
- 英文 basis「Jupiter in the 9th house」对中文证据 → 通过;
|
||||
- evidence 为 JSON 数组字符串 → 仍 `evidence_missing`。
|
||||
- 前端:修订调用收到的失败列表含 detail,且截断生效;合盘失败页顶栏为「合盘报告」。
|
||||
- 现有合盘、#281、#289 套件全部通过。
|
||||
|
||||
## 4. 记录
|
||||
|
||||
- BUG_HISTORY 新增 BUG-1897(不写任何出生资料、模型原文)。CHANGELOG 一条。
|
||||
- PROGRESS 文件写明规范化规则表和测试列表。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 2.1–2.4 全部完成,3 的用例在。
|
||||
2. Python 快速门、tsc、ESLint 0 错误、定向前端套件通过。
|
||||
3. Claude 验收时用公开名人盘对真实模型跑一次中英文(需产品负责人批准费用):报告能生成;若仍被拒,失败中不再有 1.1 那类纯格式的 basis_mismatch,evidence_missing 明显下降。
|
||||
@@ -556,7 +556,8 @@ export function PersonalReportPage({
|
||||
if (state.phase === "failed") {
|
||||
return (
|
||||
<>
|
||||
<SecondaryHeader title="个人报告" />
|
||||
{/* BUG-1897: a failed synastry run keeps the synastry title used on line 453. */}
|
||||
<SecondaryHeader title={state.reportType === "synastry_full" ? "婚恋合盘" : "个人报告"} />
|
||||
<div className="personal-report-state">
|
||||
<TriangleAlert aria-hidden="true" className="size-8 text-warning" />
|
||||
<h1>报告生成失败</h1>
|
||||
|
||||
@@ -162,6 +162,21 @@ function headingFailures(parts: ReadonlyMap<SynastrySectionId, Record<string, un
|
||||
return failures;
|
||||
}
|
||||
|
||||
// BUG-1897: the repair round names the failing word or evidence, not only the code.
|
||||
export const REPAIR_NOTE_LIMIT = 20;
|
||||
export function repairNotes(
|
||||
failures: readonly Readonly<{ section: string; code: string; detail?: string }>[],
|
||||
sectionId: string,
|
||||
): string[] {
|
||||
const notes = failures
|
||||
.filter(({ section }) => section === sectionId || section === "delivery")
|
||||
.map(({ code, detail }) => {
|
||||
const text = (detail ?? "").replace(/\s+/g, " ").trim();
|
||||
return text ? `${code}: ${text.length > 80 ? `${text.slice(0, 80)}…` : text}` : code;
|
||||
});
|
||||
return [...new Set(notes)].slice(0, REPAIR_NOTE_LIMIT);
|
||||
}
|
||||
|
||||
export async function writeSynastryLanguage(input: Readonly<{
|
||||
language: "zh" | "en";
|
||||
names: Readonly<{ a: string; b: string }>;
|
||||
@@ -285,8 +300,7 @@ export async function writeSynastryLanguage(input: Readonly<{
|
||||
const failed = new Set(gated.failures.map(({ section }) => section));
|
||||
const targets = order.filter((id) => failed.has(id));
|
||||
const retry = failed.has("delivery") || targets.length === 0 ? order : targets;
|
||||
await pool(retry, 3, (id) => writeOne(id, gated.failures
|
||||
.filter(({ section }) => section === id || section === "delivery").map(({ code }) => code)));
|
||||
await pool(retry, 3, (id) => writeOne(id, repairNotes(gated.failures, id)));
|
||||
parts = materialize();
|
||||
gated = await check(parts);
|
||||
}
|
||||
|
||||
@@ -466,17 +466,37 @@ Gate: label must be one of ${EVIDENCE_LABELS.join(", ")}. Each English section i
|
||||
|
||||
const V3_SYSTEM = {
|
||||
zh: `写一节婚恋合盘正文。采用下方占星师文件的写作方法和十节结构。只返回本节 JSON,不在正文里重复标题。
|
||||
只用本节给出的依据。每条分析含 plain、basis、evidence 和 label。evidence 逐字复制一个完整证据字段,basis 不能添加原字段没有的盘面事实。叙述节给两到三条分析,应期和现实建议按各自的字段填写。label 只能是 ${EVIDENCE_LABELS.join("、")}。
|
||||
只用本节给出的依据。每条分析含 plain、basis、evidence 和 label。evidence 只放一条:从证据 JSON 里原样复制某一个完整字符串,不能放数组、对象或拼接多条;需要多条证据就分成多条分析。basis 提到的星体、星座、宫位和分盘都必须出现在这一条 evidence 里。叙述节给两到三条分析,应期和现实建议按各自的字段填写。label 只能是 ${EVIDENCE_LABELS.join("、")}。
|
||||
reading_scope 为 limited 时只写条件,不用 multi_system_consensus。出生时间误差内会变的层已经算出,只能作条件依据。缩写首次用括号解释。
|
||||
正文不点名补充方法,不写分数、出生资料、确定的婚期或分手年份。第一句从双方能够感到的体验出发。每节至少 100 字,整篇至少 2000 字。每节按实际证据写到约 220 字,给整篇留出余量。
|
||||
11-15 Q1 适用于附录:附录由程序生成,模型不写附录。正文照写作合同,只用允许的证据,不输出工程字段或私有材料。`,
|
||||
en: `Write one section of a synastry report using the supplied writing contracts and ten-section reader structure. Return only this section's JSON, with no repeated heading in the body.
|
||||
Use this section's evidence only. Each narrative section gets two or three reasons with plain, basis, evidence and label. Timing and practical advice use their own fields. Copy one complete input string into evidence. Do not add chart facts to basis. Allowed labels: ${EVIDENCE_LABELS.join(", ")}.
|
||||
Use this section's evidence only. Each narrative section gets two or three reasons with plain, basis, evidence and label. Timing and practical advice use their own fields. evidence holds exactly one complete string copied from the evidence JSON: never an array, an object or several strings joined. Split into separate reasons when you need more evidence. Every planet, sign, house and chart named in basis must appear in that evidence string. Allowed labels: ${EVIDENCE_LABELS.join(", ")}.
|
||||
A limited reading_scope allows conditional findings only, without multi_system_consensus. Layers that vary in a birth-time window have been computed and remain conditional. Explain an abbreviation in parentheses on first use.
|
||||
Keep supplementary method names, scores, birth details and definitive event years out of the prose. Start with both people's lived experience. Each section needs at least 70 words, with at least 1200 words overall. Aim for 130 words when the evidence supports it.
|
||||
Under the November 15 Q1 ruling, the appendix is assembled by the application. Do not write it. Use the writing contract for the narrative and keep private material and engineering fields out of the response.`,
|
||||
} as const;
|
||||
|
||||
// BUG-1897: one fixed instruction per gate code, shown beside the failing word in the repair round.
|
||||
const REPAIR_HINTS: Readonly<Record<string, Readonly<{ zh: string; en: string }>>> = {
|
||||
repeated_heading: { zh: "正文不要以节标题开头", en: "do not start the body with the section title" },
|
||||
basis_mismatch: {
|
||||
zh: "basis 里这个星体、星座、宫位或分盘在所选 evidence 原文里找不到;换一条含它的证据,或从 basis 删掉",
|
||||
en: "this planet, sign, house or chart in basis is not in the chosen evidence string; pick evidence that contains it or remove it from basis",
|
||||
},
|
||||
evidence_missing: {
|
||||
zh: "evidence 必须是证据 JSON 里某一条完整字符串,原样复制;不能粘数组、对象或拼接多条",
|
||||
en: "evidence must be one complete string from the evidence JSON, copied as is; never an array, an object or several strings joined",
|
||||
},
|
||||
unexplained_term: { zh: "这个缩写第一次出现时在括号里解释", en: "explain this abbreviation in parentheses on first use" },
|
||||
banned_judgement: { zh: "删掉这个断语,改写成条件和倾向", en: "remove this verdict and describe conditions and tendencies instead" },
|
||||
score: { zh: "正文不写分数", en: "keep scores out of the prose" },
|
||||
scan_window_as_event: {
|
||||
zh: "研究扫描的日期只能写成研究参考,不能写成两人的事件",
|
||||
en: "scan dates are research reference only, never a shared event",
|
||||
},
|
||||
};
|
||||
|
||||
export function systemPrompt(language: "zh" | "en", structure: SynastryStructure = "skill_v2"): string {
|
||||
return structure === "skill_v3" ? V3_SYSTEM[language] : language === "zh" ? ZH_SYSTEM : EN_SYSTEM;
|
||||
}
|
||||
@@ -498,11 +518,10 @@ export function userPrompt(input: Readonly<{
|
||||
en: "Use only the formal check (formal_gate) for conclusions about a shared event. Dates from the scan are for research reference only. Leave dates out of first contact and going public. Annual evidence is missing for legal marriage and the strict check cannot pass.",
|
||||
}[input.language] : "";
|
||||
const evidence = JSON.stringify(input.evidence);
|
||||
const notes = (input.failures ?? []).map((code) => {
|
||||
if (code !== "repeated_heading") return code;
|
||||
return input.language === "en"
|
||||
? "repeated_heading (do not start the body with the section title)"
|
||||
: "repeated_heading(正文不要以节标题开头)";
|
||||
const notes = (input.failures ?? []).map((note) => {
|
||||
const code = note.split(":", 1)[0];
|
||||
const hint = REPAIR_HINTS[code]?.[input.language];
|
||||
return hint ? `${note}${input.language === "en" ? ` (${hint})` : `(${hint})`}` : note;
|
||||
});
|
||||
if (input.language === "en") {
|
||||
const repair = notes.length > 0
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { REPAIR_NOTE_LIMIT, repairNotes } from "../src/lib/synastry-full-generate.ts";
|
||||
import { systemPrompt, userPrompt } from "../src/lib/synastry-full-sections.ts";
|
||||
|
||||
test("the repair round carries the failing detail, truncated and capped (BUG-1897)", () => {
|
||||
const long = "x".repeat(200);
|
||||
const notes = repairNotes([
|
||||
{ section: "climate", code: "basis_mismatch", detail: "第9宫" },
|
||||
{ section: "climate", code: "basis_mismatch", detail: "第9宫" },
|
||||
{ section: "climate", code: "evidence_missing", detail: long },
|
||||
{ section: "emotion", code: "basis_mismatch", detail: "木星" },
|
||||
{ section: "delivery", code: "section_order" },
|
||||
], "climate");
|
||||
assert.deepEqual(notes.slice(0, 1), ["basis_mismatch: 第9宫"]);
|
||||
assert.equal(notes.length, 3);
|
||||
assert.ok(notes[1].startsWith("evidence_missing: x") && notes[1].length <= 100);
|
||||
assert.equal(notes[2], "section_order");
|
||||
const many = Array.from({ length: 40 }, (_, i) => ({ section: "climate", code: "basis_mismatch", detail: `t${i}` }));
|
||||
assert.equal(repairNotes(many, "climate").length, REPAIR_NOTE_LIMIT);
|
||||
});
|
||||
|
||||
test("repair notes come with a fixed instruction per code", () => {
|
||||
const zh = userPrompt({ language: "zh", names: { a: "甲", b: "乙" }, sectionId: "climate", evidence: {}, failures: ["basis_mismatch: 第9宫", "evidence_missing: [..."], structure: "skill_v3" });
|
||||
assert.match(zh, /basis_mismatch: 第9宫(basis 里这个星体/);
|
||||
assert.match(zh, /不能粘数组、对象或拼接多条/);
|
||||
const en = userPrompt({ language: "en", names: { a: "A", b: "B" }, sectionId: "climate", evidence: {}, failures: ["repeated_heading"], structure: "skill_v3" });
|
||||
assert.match(en, /repeated_heading \(do not start the body with the section title\)/);
|
||||
});
|
||||
|
||||
test("the ten-section prompt requires exactly one evidence string", () => {
|
||||
assert.match(systemPrompt("zh", "skill_v3"), /evidence 只放一条/);
|
||||
assert.match(systemPrompt("en", "skill_v3"), /evidence holds exactly one complete string/);
|
||||
});
|
||||
|
||||
test("a failed synastry run keeps the synastry page title", () => {
|
||||
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
|
||||
const failed = page.slice(page.indexOf('state.phase === "failed"'));
|
||||
assert.match(failed.slice(0, 400), /state\.reportType === "synastry_full" \? "婚恋合盘" : "个人报告"/);
|
||||
});
|
||||
@@ -139,7 +139,7 @@ ZH_EVENT = re.compile(r"(19|20)\d{2}\s*年.{0,12}(结婚|领证|分手|离婚)")
|
||||
EN_EVENT = re.compile(r"\b(19|20)\d{2}\b.{0,24}\b(marry|married|marriage|divorce|break up)\b", re.I)
|
||||
ZH_SCORE = re.compile(r"\d+\s*分")
|
||||
EN_SCORE = re.compile(r"\b\d+\s*(points|point|score)\b", re.I)
|
||||
ZH_HOUSE = re.compile(r"(?:第)?[一二三四五六七八九十\d]{1,3}宫")
|
||||
ZH_HOUSE = re.compile(r"(?:第\s*)?[一二三四五六七八九十\d]{1,3}\s*宫")
|
||||
EN_HOUSE = re.compile(r"\b(?:house\s*)?\d{1,2}(?:st|nd|rd|th)?\s+house\b", re.I)
|
||||
D_CHART = re.compile(r"(?<![A-Za-z])D\d{1,2}(?![A-Za-z])", re.I)
|
||||
|
||||
@@ -188,6 +188,108 @@ def _basis_tokens(basis: str, language: str) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
# BUG-1897: program evidence mixes Chinese and English names and writes houses
|
||||
# as 「第 9 宫」. Compare basis and evidence on canonical keys, not raw spelling.
|
||||
# Nakshatra pairing follows the standard 27-nakshatra / lunar-mansion table
|
||||
# (牛宿 has no nakshatra); EN_NAKSHATRAS folds Purva/Uttara into one name.
|
||||
_ZH_NAKSHATRA_KEYS = dict(zip(ZH_NAKSHATRAS, (
|
||||
"ashwini", "bharani", "krittika", "rohini", "mrigashira", "ardra", "punarvasu",
|
||||
"pushya", "ashlesha", "magha", "phalguni", "phalguni", "hasta", "chitra", "swati",
|
||||
"vishakha", "anuradha", "jyeshtha", "mula", "ashadha", "ashadha", "shravana",
|
||||
"dhanishta", "shatabhisha", "bhadrapada", "bhadrapada", "revati",
|
||||
)))
|
||||
_ZH_NUMERALS = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||||
ZH_HOUSE_KEY = re.compile(r"(?:第\s*)?([一二三四五六七八九十]{1,3}|\d{1,2})\s*宫")
|
||||
EN_HOUSE_KEY = re.compile(r"\b(\d{1,2})(?:st|nd|rd|th)?[\s-]+house\b|\bhouse\s+(\d{1,2})\b|\bH(\d{1,2})\b", re.I)
|
||||
D_CHART_KEY = re.compile(r"(?<![A-Za-z])D\s*-?\s*(\d{1,2})(?![A-Za-z\d])", re.I)
|
||||
|
||||
|
||||
def _house_number(text: str) -> int | None:
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
if text == "十":
|
||||
return 10
|
||||
if text.startswith("十"):
|
||||
return 10 + _ZH_NUMERALS.get(text[1:], 0)
|
||||
return _ZH_NUMERALS.get(text)
|
||||
|
||||
|
||||
def _basis_key(token: str) -> str | None:
|
||||
"""Canonical key for one basis token; None means the token is not compared."""
|
||||
lowered = token.lower()
|
||||
if token in ZH_PLANETS:
|
||||
return "p:" + EN_PLANETS[ZH_PLANETS.index(token)]
|
||||
if lowered in EN_PLANETS:
|
||||
return "p:" + lowered
|
||||
if token in ZH_SIGNS:
|
||||
return "s:" + EN_SIGNS[ZH_SIGNS.index(token)]
|
||||
if lowered in EN_SIGNS:
|
||||
return "s:" + lowered
|
||||
if token in _ZH_NAKSHATRA_KEYS:
|
||||
return "n:" + _ZH_NAKSHATRA_KEYS[token]
|
||||
if lowered in EN_NAKSHATRAS:
|
||||
return "n:" + lowered
|
||||
if match := ZH_HOUSE_KEY.fullmatch(token.strip()):
|
||||
number = _house_number(match.group(1))
|
||||
return f"h:{number}" if number else None
|
||||
if match := EN_HOUSE_KEY.fullmatch(token.strip()):
|
||||
return "h:" + str(int(next(group for group in match.groups() if group)))
|
||||
if match := D_CHART_KEY.fullmatch(token.strip()):
|
||||
# D1 is the birth chart itself: program evidence names houses and
|
||||
# planets without a D1 marker, so a D1 basis is not compared.
|
||||
number = int(match.group(1))
|
||||
return None if number == 1 else f"d:{number}"
|
||||
if lowered in ("大运", "小运", "dasha"):
|
||||
return "dasha:" + lowered
|
||||
return "raw:" + lowered
|
||||
|
||||
|
||||
def _evidence_keys(evidence: str) -> set[str]:
|
||||
"""Every canonical key the evidence string supports, in either language."""
|
||||
keys: set[str] = set()
|
||||
lowered = evidence.lower()
|
||||
for zh, en in zip(ZH_PLANETS, EN_PLANETS):
|
||||
if zh in evidence or re.search(rf"\b{en}\b", lowered):
|
||||
keys.add("p:" + en)
|
||||
for zh, en in zip(ZH_SIGNS, EN_SIGNS):
|
||||
if zh in evidence or re.search(rf"\b{en}\b", lowered):
|
||||
keys.add("s:" + en)
|
||||
for zh, en in _ZH_NAKSHATRA_KEYS.items():
|
||||
if zh in evidence:
|
||||
keys.add("n:" + en)
|
||||
for en in EN_NAKSHATRAS:
|
||||
if re.search(rf"\b{en}\b", lowered):
|
||||
keys.add("n:" + en)
|
||||
for match in ZH_HOUSE_KEY.finditer(evidence):
|
||||
number = _house_number(match.group(1))
|
||||
if number:
|
||||
keys.add(f"h:{number}")
|
||||
for match in EN_HOUSE_KEY.finditer(evidence):
|
||||
keys.add("h:" + str(int(next(group for group in match.groups() if group))))
|
||||
for match in D_CHART_KEY.finditer(evidence):
|
||||
keys.add(f"d:{int(match.group(1))}")
|
||||
for word in ("大运", "小运"):
|
||||
if word in evidence:
|
||||
keys.add("dasha:" + word)
|
||||
if re.search(r"\bdasha\b", lowered):
|
||||
keys.add("dasha:dasha")
|
||||
return keys
|
||||
|
||||
|
||||
def _basis_mismatches(basis: str, evidence: str, language: str) -> list[str]:
|
||||
"""Basis tokens the evidence does not support after normalization."""
|
||||
keys = _evidence_keys(evidence)
|
||||
missing = []
|
||||
for token in _basis_tokens(basis, language):
|
||||
key = _basis_key(token)
|
||||
if key is None or key in keys:
|
||||
continue
|
||||
if key.startswith("raw:") and (token.lower() in evidence.lower() or token in evidence):
|
||||
continue
|
||||
missing.append(token)
|
||||
return missing
|
||||
|
||||
|
||||
def _string_values(value: Any) -> set[str]:
|
||||
"""Complete string field values. A one-character slice of a longer field does not count."""
|
||||
found: set[str] = set()
|
||||
@@ -503,9 +605,8 @@ def validate_synastry_reader(
|
||||
elif scope == "limited" and label == "multi_system_consensus":
|
||||
# delivery_readiness not ready: no full conclusion (builder:1154).
|
||||
failures.append(_fail(section_id, "scope_limited_consensus", label))
|
||||
for token in _basis_tokens(basis, language):
|
||||
if token.lower() not in evidence.lower() and token not in evidence:
|
||||
failures.append(_fail(section_id, "basis_mismatch", token))
|
||||
for token in _basis_mismatches(basis, evidence, language):
|
||||
failures.append(_fail(section_id, "basis_mismatch", token))
|
||||
|
||||
timeline = by_id.get("timeline") if isinstance(by_id.get("timeline"), dict) else {}
|
||||
segments = timeline.get("segments") if isinstance(timeline, dict) else None
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""BUG-1897: basis/evidence comparison is normalized, not literal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from marriage_match_service.synastry_full_reader_gate import ( # noqa: E402
|
||||
_basis_key, _basis_mismatches, _evidence_keys,
|
||||
)
|
||||
|
||||
MIXED = "甲 关系承诺:7宫主 Jupiter 落第 9 宫 Aquarius(入友,功能凶星);金星 落第 10 宫 Pisces;D9 上升 Aquarius"
|
||||
|
||||
|
||||
def test_chinese_basis_matches_mixed_evidence() -> None:
|
||||
assert _basis_mismatches("木星落第9宫水瓶,金星在第十宫双鱼,D9 上升", MIXED, "zh") == []
|
||||
|
||||
|
||||
def test_house_spellings_share_one_key() -> None:
|
||||
assert {_basis_key(t) for t in ("第9宫", "第 9 宫", "9宫", "第九宫")} == {"h:9"}
|
||||
assert _basis_key("第十二宫") == "h:12"
|
||||
assert {_basis_key(t) for t in ("9th house", "9th house")} == {"h:9"}
|
||||
|
||||
|
||||
def test_wrong_house_or_planet_still_fails() -> None:
|
||||
assert sorted(_basis_mismatches("第4宫有土星", MIXED, "zh")) == sorted(["土星", "第4宫"])
|
||||
assert sorted(_basis_mismatches("Saturn in the 4th house", MIXED, "en")) == ["4th house", "saturn"]
|
||||
|
||||
|
||||
def test_english_basis_matches_chinese_evidence() -> None:
|
||||
assert _basis_mismatches("Jupiter in the 9th house in Aquarius; Venus in Pisces; D9", MIXED, "en") == []
|
||||
|
||||
|
||||
def test_d1_is_the_birth_chart_and_is_not_compared() -> None:
|
||||
assert _basis_key("D1") is None
|
||||
assert _basis_mismatches("D1 第9宫 木星", MIXED, "zh") == []
|
||||
assert _basis_mismatches("D10 第9宫", MIXED, "zh") == ["D10"]
|
||||
|
||||
|
||||
def test_nakshatra_names_pair_across_languages() -> None:
|
||||
assert _basis_mismatches("月亮在娄宿", "Moon in Ashwini", "zh") == []
|
||||
assert "n:phalguni" in _evidence_keys("Venus in Purva Phalguni")
|
||||
|
||||
|
||||
def test_array_evidence_is_still_rejected() -> None:
|
||||
from marriage_match_service.synastry_full_reader_gate import _string_values
|
||||
|
||||
inputs = {"climate": {"a": "甲 第 9 宫 Jupiter", "b": "乙 第 7 宫 Venus"}}
|
||||
pasted = '["甲 第 9 宫 Jupiter", "乙 第 7 宫 Venus"]'
|
||||
assert pasted not in _string_values(inputs["climate"])
|
||||
Reference in New Issue
Block a user