Compare commits

...
Author SHA1 Message Date
jesse-ux 70e3fda184 docs(task): record #183 rework verification
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m33s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 6m56s
Independent Staging Quality Gate / publish (pull_request) Skipped
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m49s
Independent Staging Quality Gate / validate-python (push) Successful in 8m8s
Independent Staging Quality Gate / publish (push) Successful in 2m59s
2026-11-11 17:47:31 +08:00
jesse-ux e5e087daf7 fix(consult): bind varga house claims to the named planet
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m36s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m19s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-11 17:30:49 +08:00
jesse-ux 1a24e4f184 merge: update #183 to current staging 2026-11-11 17:24:44 +08:00
ClaudeandClaude Opus 5.5 016d267711 docs(bug): BUG-1820 acceptance record (#177)
Independent Staging Quality Gate / validate-python (push) Successful in 6m42s
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m25s
Independent Staging Quality Gate / publish (push) Successful in 2m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 17:20:03 +08:00
ClaudeandClaude Opus 5.5 0b6e9d7c57 docs(task): FIX-d9-reference-guard-1 (#183 acceptance fail: 2 false positives)
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m39s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m3s
Independent Staging Quality Gate / publish (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 17:13:07 +08:00
ClaudeandClaude Opus 5.5 bafe6b6b70 merge: #177 on-demand dasha_date (codex/ondemand-dasha-date-20261111 @ 0b701a53, BUG-1820)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 17:12:14 +08:00
jesse-ux 4c9e25ce58 docs(task): record varga guard validation and baseline
Independent Staging Quality Gate / validate-python (pull_request) Successful in 7m35s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 8m30s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-11 16:12:06 +08:00
jesse-ux fafabf8e48 fix(consult): verify explicit varga house reference points
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m39s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m10s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-11 15:50:54 +08:00
ClaudeandClaude Opus 5.5 3fe65bfc23 docs(task): brief for d9-reference-guard (#183, BUG-1826, 2026-11-11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-11 14:54:53 +08:00
6 changed files with 778 additions and 2 deletions
+14 -1
View File
@@ -24776,4 +24776,17 @@
- 修复:注册 `dasha_date`,直接读取原生日期。Narayana 未决时保留具名分支及其独立日期,主轨只包含已确定的段。旧版条件分支不参与主轨状态。
- 验证与防复发:`tests/test_ondemand_dasha_date_20261111.py` 覆盖原生日期逐字段对照、出生余额、日期边界、第二轮、0 年段、分支与未穷尽、输入口径及错误信封。实测见 `docs/tasks/PROGRESS-ondemand-dasha-date-20261111.md`。
- 关联记录:BUG-1556、BUG-1811、BUG-1819、BUG-1829、BUG-1830、#177。
- 修复版本:`codex/ondemand-dasha-date-20261111`。
- 验收(2026-11-11,Claude):审查 0b701a53,范围只有新工具文件、注册表两行、新测试和文档,没有碰禁改文件。Linux 上 Python 快速门全部通过(Windows 报告的 3 条 `test_upstream_technique_layers` 失败在 staging 和本单都没有复现)。tsc 0,eslint 0 error;前端全量 3631 条,失败 27 条,逐条与 staging 15056452 的基线相同。
- 修复版本:`codex/ondemand-dasha-date-20261111` @ 0b701a53,经验收分支 `codex/accept-batch6-1111` 合入 staging。
## BUG-1826 | 回答拦截漏查分盘宫位的起算点
- 状态:fixed-pending-verify(待独立验收)
- 首次发现 / 最近更新:2026-11-09 / 2026-11-11
- 现象与触发条件:回答把从分盘另一星座数出的宫数写为从该分盘上升数,证据卡虽有行星与上升的星座,拦截未拒收该句。
- 根因:原规则只检查分盘星座、上升和宫主。显式起算措辞也会阻止通用位置规则将其当作本命宫位处理。
- 修复:新增 `varga_house`,在同句同格绑定层、实体、起算点、整宫关系与句中值,给出现有重写流程可用的卡上值。
- 验证与防复发:`frontend/tests/consult-varga-house-reference-1826.test.ts` 覆盖原反例、不同起算点、分盘冲突、表格和重写。实测及边界见 `docs/tasks/PROGRESS-d9-reference-guard-20261111.md`。
- 关联记录:BUG-1789、BUG-1825、#183。
- 修复版本:`codex/d9-reference-guard-20261111`。
- 验收退回第 1 次(2026-11-11,评论 40947):回放发现宫数后出现行星和「第 N 宫的宫主」两类误拦。返工优先绑定紧跟宫数的行星,排除宫主描述,向前找实体时在分号、破折号和冒号处停止。新增合成回归覆盖这两类问题,待独立回放复验。
+70
View File
@@ -0,0 +1,70 @@
# FIX:分盘宫位检查的实体绑定误拦(#183,BUG-1826,第 1 次退回,2026-11-11)
- 验收提交:`4c9e25ce585510fac579fe5845afff65f2ed4892`(分支 `codex/d9-reference-guard-20261111`)
- 原任务书:`docs/tasks/TASK-d9-reference-guard-20261111.md`(其余要求不变)
不得自行写“产品决定/裁定”。
不得修改或删除既有断言来让测试通过。
## 验收结果
不调用模型,确定性回放既有回答:第七轮验收包 10 份 + phase2-chat 30 组生成目录,共 1844 份回答。
`varga_house` 共 105 条:4 条不符、101 条相符。4 条不符里 2 条是真错(她的原句,两次出现),另外 2 条把**正确的句子**判成不符。
标准是正确的 D9 句子 0 误拦,所以本次退回。
### 误拦 1:实体写在宫数后面
(公开名人盘,wealth)D9 上升天蝎,水星 D9 射手,金星 D9 双鱼。
> (……11 宫主金星同时是功能凶星;D9 从上升数第 2 宫的水星入敌;……)
这句说的是水星在第 2 宫,从天蝎数到射手正是第 2 宫,所以句子是对的。
现在的实现取“宫数之前最近的行星”,于是取到了前一分句里的金星,判成不符(金星应为第 5 宫)。
### 误拦 2:「第 N 宫的宫主」是宫主,不是落宫
(公开名人盘,marriage)D9 上升巨蟹,土星 D9 白羊。
> D9 里第 7 宫为摩羯,主星土星落在白羊……只说明……:从 D9 上升数第 7 宫的宫主本身需要「取消落陷」才站得住。
这句说的是第 7 宫的宫主(土星),不是说土星落在第 7 宫,所以句子是对的。
现在的实现把土星绑成“落第 7 宫”,判成不符(土星应为第 10 宫)。
## 要改的
只改 `checkVargaHouse`。不改任务书禁止的部分(`BREAKER`、`layerSpans`、#182 的检查、已有检查)。
1. **宫数后面紧跟宫主写法时不检查。** 宫数后面紧跟(中间只允许空白和「的」)`宫主`、`主星`、`之主`、`主` 时,这一句里的这个宫数不产生 `varga_house` 记录,也不记 `not_on_card`。
2. **宫数后面紧跟行星时,实体取后面这颗。** 宫数后面紧跟(中间只允许空白、`的`、`里的`、`内的`、`中的`)一个行星名时,实体就是这颗行星,不再往前找。
- 这颗行星在这一层的星座照第 2 节第 2 条核对:句中写了星座就必须与卡一致,没写就用卡上的值。
- 前后两边都没有可用行星时,照旧不检查。
3. **实体不跨分号、破折号、冒号。** 从宫数往前找实体时,不得越过 `;`、`;`、`——`、`:`、`:`。找不到就不检查。
- 括号不算分隔:「太阳水瓶(从 D6 上升数第 12 宫)」照旧检查。
- 若这条与已有测试冲突,就停下来写进 PROGRESS,选需拍板,不要改旧断言。
## 必须加的测试(写进 `frontend/tests/consult-varga-house-reference-1826.test.ts`,只新增)
用文件里已有的合成卡思路,另建一张卡:D9 上升天蝎、水星射手、金星双鱼、土星白羊(上升巨蟹的卡另建)。
1. 「11 宫主金星同时是功能凶星;D9 从上升数第 2 宫的水星入敌。」→ 0 条不符,1 条 `match`,`entity: "Mercury"`。
2. 同句写成「D9 从上升数第 5 宫的水星入敌。」→ 1 条不符,`entity: "Mercury"`。
3. D9 上升巨蟹、土星白羊:「D9 里第 7 宫为摩羯,主星土星落在白羊……从 D9 上升数第 7 宫的宫主本身需要取消落陷。」→ 0 条 `varga_house`。
另加「从 D9 上升数第 7 宫之主」「从 D9 上升数第 7 宫主星」两种写法,同样为 0 条。
4. 「金星在 D9 双鱼;从上升数第 2 宫。」→ 0 条(分号隔开)。
5. 原有 9 个测试原样通过。她的原句仍然恰好 1 条不符。
## 验收时会再做的
用同一套回放(`/workspace/eval-blind-1107/phase2-chat/d9guard/varga_house_replay.mts`,1844 份回答)复跑:
- 正确句子 0 误拦;
- 她的原句仍被拦下;
- 相符条数不得因为改了绑定而变成误拦。
## 进度记录
追加到 `docs/tasks/PROGRESS-d9-reference-guard-20261111.md`:
- 改了什么;
- 新测试名;
- 命令和通过数。
`docs/BUG_HISTORY.md` 只在 BUG-1826 条目内追加一行“验收退回第 1 次”,不得用 sed 改整个文件。
@@ -0,0 +1,210 @@
# PROGRESS · 分盘宫位起算点检查(#183,BUG-1826,2026-11-11)
- 基线为 staging `1505645287c59d53bc0c608c2c10b2da32d61f6a`。
- 工单分支为 `codex/d9-reference-guard-20261111`。
- 实施依据见 `docs/tasks/TASK-d9-reference-guard-20261111.md`。
## 实现
在既有回答拦截末尾新增 `varga_house` 检查。它先在一句或一格内绑定显式分盘、宫数、
起算点和该宫数前最近的实体,再用卡片的星座从指定起算点按整宫计数。修正提示给出正确
宫数,卡上有该盘上升时也给出原宫数对应的起算星座。
基线原句里的「月亮在 D9 金牛」因分盘名插在行星和星座之间,旧星座检查没有识别。
新检查在核实这种内联星座与卡一致后,同时记录已确认的 `varga_sign`,保留原句要求的
星座证据。原有星座规则、格局状态规则、`BREAKER`、`layerSpans`、去重和重写判定保留。
当宫数在同一括号的起算点前面,新宫位事实的定位落在起算点,使原星座事实与宫位事实
各有自己的文本范围。
## 覆盖范围
| 句式 | 验证 |
| --- | --- |
| D9 原反例 | 恰好一条宫位不符,绑定 D9、Moon、D9 上升、11 宫与卡上 5 宫。提示巨蟹为原值对应的起算星座 |
| 起算点变更 | D9 上升、上升起算、Lagna、巨蟹数/起/为第 1 宫、D9 金星数/起 |
| 值的位置 | 紧接起算点,或位于同一括号的起算点前面 |
| 其他分盘 | D10 例检查实体星座来源。卡片跨领域给出冲突 D10 时跳过 |
| 分句 | 正文按 。!?; 和换行断句,表格按格处理,格内同样保留逗号 |
| 修复 | 原值再次出现返回 `still_mismatched`,改为正确宫数后通过 |
本次覆盖范围限于阿拉伯数字 1–12、任务书列明的三类起算点及已有显式分盘范围。中文
数字、未写起算点、分开的括号、跨句跨格的实体或起算点,以及 Karakamsha、AL/UL、
月亮盘、D1 参照、行运、年盘和 KP 等其他体系不进入该检查。星座有歧义或与卡不符、卡
缺层或缺值,以及无法唯一配对的宫数与参照也跳过。
真实回答抽查与精度统计由独立验收侧完成。
## 验证
- 四份定向套件共 39 项通过,0 失败、0 取消。原 `consult-answer-fact-guard-1789`、
流式重写与 `consult-yoga-status-wording-1825` 的断言均原样保留。
- TypeScript `--noEmit` 通过。ESLint 0 error、62 warnings。隐私检查 62 项通过。
- 两份生产构建退出码均为 0,首页 `/` 均为 Static。以首页 HTML 直接引用的 36 份
JS 资源按路径去重后分别 gzip,staging 和本单均为 515086 字节,变动 0%。基线构建
另有 Windows 文件跟踪复制警告。
- staging 基线 `150564528` 的 Gitea 全量前端门禁 3631 通过、0 失败、0 取消。
PR 代码提交 `fafabf8e4` 的 Gitea 全量前端测试 3640 通过、0 失败、0 取消。
- Windows 本机 `npm test -- --test-concurrency=4` 退出码 1,3231 通过、76 失败、0
取消。失败包含 Windows 路径和 Shell 兼容、Docker 夹具、启动子进程失败及断言。
将 32 个文件级失败单独以并发 1 重跑,365 项全部通过。管理员兑换码的单条失败
定向重跑也通过。
- 同机 staging 基线全量退出码 1,3599 通过、32 失败、0 取消。以两边 TAP 的顶层
测试名逐项对照,将本单 32 个文件级失败的占位行替换为重跑名录后,staging 的
3611 个测试名全部保留,新增 9 个。本机失败名有 30 个在两边重合,含两条回答
时钟断言。其余 13 个本单首轮失败未在基线复现,下文标出。同机基线另有
`synastry reports reuse a pair, drop with the person, and settle the synastry.full reservation`
和 `database roles have no cluster privileges` 两项本单通过的失败。两组 Gitea
全量前端测试均通过,Windows 首轮失败保留在此供验收核查。
### Windows 本机首次全量失败名
以下为 TAP 失败行去重后的测试名或测试文件。
- `admin code functions reject immutable codes, revoked redemption, and roll back on audit failure`
- `operation-level admin email reauthentication is removed from routes and UI`
- `a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase`
- `the answer clock cutting the answer step mid-sentence is truncated, recorded and not charged`
- `dated adoption 23:59 persists date/spec atomically and rejects malformed candidates`(基线通过)
- `staging backups are encrypted, atomic, private, and retain the newest three`
- `rejects destructive backup directory aliases and symlink components before mutation`
- `rejects unsafe writable backup parents before creating the target`
- `rejects a direct canonical sticky shared backup directory before chmod`
- `creates every absent backup path component privately despite a permissive caller umask`
- `same-second backups publish once without overwriting the completed archive`
- `find enumeration failures preserve existing backups and do not report completion`
- `refuses full disks and removes a failed-pipeline partial file`
- `actual subject deletion releases queued and running reservations once but never refunds delivered reports`(基线通过)
- `database env validator accepts punctuated literal required secrets`
- `database env validator enforces an explicit staging owner uid without printing values`
- `database env validator accepts a private valid file without printing values`
- `migration runner is serialized, atomic, drift-safe, and read-only in check mode`(基线通过)
- `foundation grants no direct runtime table DML and exposes only reviewed functions`(基线通过)
- `personal report job migration is atomic, lease-bound, recoverable, and owner read-only`
- `personal report sections enforce owner-read RLS and service-owned durable transitions`
- `rectification session titles follow the result and do not bump updated_at`(基线通过)
- `a delivered report found by recovery is never refunded`(基线通过)
- `self-hosted identity migration creates Better Auth tables with least privilege`(基线通过)
- `session list query drops retired rectification rows and empty consultations`(基线通过)
- `retire archive migration clears archived_at without bumping updated_at`(基线通过)
- `createSynastryFullReport reserves synastry.full and enqueues a job on the real client`
- `synastry chat binds an owned section, rejects another report, and stays readable after delete`(基线通过)
- `staging model provider env preparation removes legacy settings and keeps one stable key`
- `staging env validator rejects selector drift, duplicates, and unsafe permissions`
- `production env validators accept only self-hosted production selectors and role URLs`
- `Better Auth supports shared user OTP/password sessions for admins`(基线通过)
- `BUG-1526: outside self-hosted auth the profile read does not ask for avatar columns`(基线通过)
- `tests/notify-toasts-20260930.test.ts`
- `tests/people-archive-order.test.ts`
- `tests/people-archive-regressions.test.ts`
- `tests/people-archive-view.test.tsx`
- `tests/people-pagination-lifecycle.test.tsx`
- `tests/personal-report-agent-telemetry.test.ts`
- `tests/personal-report-api.test.ts`
- `tests/personal-report-contract.test.ts`
- `tests/personal-report-density-retry.test.ts`
- `tests/personal-report-entitlement.test.ts`
- `tests/personal-report-entry.test.ts`
- `tests/personal-report-export.test.ts`
- `tests/personal-report-generation-v2.test.ts`
- `tests/personal-report-generation.test.ts`
- `tests/personal-report-job-service.test.ts`
- `tests/personal-report-job-state.test.ts`
- `tests/personal-report-longform-appendix.test.ts`
- `tests/personal-report-longform-md.test.ts`
- `tests/personal-report-markdown-view.test.ts`
- `tests/personal-report-markdown-view.test.tsx`
- `tests/personal-report-migration.test.ts`
- `tests/personal-report-plan.test.ts`
- `tests/personal-report-progress.test.ts`
- `tests/personal-report-theme-contract.test.ts`
- `tests/personal-report-view.test.ts`
- `tests/personal-report-worker-sleep-listener.test.ts`
- `tests/postgres-fixture-contract.test.ts`
- `tests/pricing-simulation.test.ts`
- `tests/product-domain-registry.test.ts`
- `tests/profile-gender-20260927.test.ts`
- `tests/profile-gender-ui-20260927.test.tsx`
- `immutable Skill migration enforces RPC-only identity, legacy adoption, and cascade-safe receipts`
- `importing the registry creates no runtime directory`
- `a used runtime root is removed when the process exits`
- `changed staging workflows are syntactically valid YAML`
- `live staging sync preserves env, state, incoming files, and encrypted backups`
- `live staging sync repairs nested deploy-tree drift without preserving foreign ownership`
- `first immutable deployment rolls back to validated local image IDs`
- `deployment scripts pass shell syntax validation`
- `is-docs-only-range.sh decides from local history and refuses non-ancestor ranges`
- `bounded npm install distinguishes forced timeout, OOM, and unknown failure and cleans its container`
- `session and report lists keep legacy self rows and hide another person`(基线通过)
- `tests/synastry-report-chat.test.ts`
## 第 1 次验收退回
验收针对 `4c9e25ce585510fac579fe5845afff65f2ed4892`。独立验收对 1844 份既有回答
回放,得到 105 条宫位记录。4 条不符中有 2 条误拦。返工按评论 40947 与
`docs/tasks/FIX-d9-reference-guard-1.md` 执行,保留同一 PR,并合入最新 staging
`016d2677111170bdf6b75fe1211752ab42752f3b` 至工单分支。合并冲突仅在 Bug 历史,
保留 staging 历史及本单 BUG-1826 条目。
- 紧跟宫数的行星优先成为实体。它若明确写了星座,仍须与证据卡一致。
- 「第 N 宫的宫主」「第 N 宫之主」「第 N 宫主星」等措辞跳过宫位检查。
- 向前绑定实体时以分号、破折号和冒号为界,括号内也生效。括号仍可将实体与其宫数连接。
新增用例名
- `a planet immediately after the house number is the entity for correct and incorrect counts`
- `a sign written after the house number and planet must agree with the evidence`
- `house-lord wording does not claim a planet placement`
- `backward entity binding stops at semicolons, dashes and colons, including inside parentheses`
在未修复的代码上四条新用例全部失败,原 9 条仍通过。修复后通过
`node node_modules/tsx/dist/cli.mjs --test --test-concurrency=2 tests/consult-varga-house-reference-1826.test.ts tests/consult-answer-fact-guard-1789.test.ts tests/consult-fact-guard-stream-1789.test.ts tests/consult-yoga-status-wording-1825.test.ts`
共 43 项,0 失败、0 取消。包括原误写仍恰好产生一条不符的断言,既有断言未改。
- Gitea [门禁 7152](https://git.copse.top/root/Jyotisha/actions/runs/7152) 对返工代码
`e5e087daf` 通过。前端全量 3644 通过、0 失败、0 取消,Python 验证任务通过。
- 本机 `npm test -- --test-concurrency=4` 退出码 1,3614 通过、30 失败、0 取消。当前
staging 与初次执行时的前端 Git tree 完全相同。对照同机 staging TAP 名录,3611 个
既有顶层测试名全部保留,本分支新增 13 个,其中本次返工新增 4 个。30 个失败里 29 个
与基线重合。数据库身份隔离用例在共享夹具目录报 `EPERM`,改用独立临时目录单独重跑
通过。Windows 全量启动后补了一个冒号歧义断言,最终代码已由四个定向套件和 Gitea
全量覆盖。
- TypeScript `--noEmit` 和生产构建通过,首页 `/` 保持 Static。首页直接引用的 36 份 JS
合计 515086 字节 gzip,与 staging 基线相同。ESLint 0 error、62 warnings,隐私检查
62 项通过。
- 1844 份回答的原回放材料位于独立验收环境,同一套回放由验收侧复跑。
<details>
<summary>本机首次全量的 30 个失败名</summary>
- `operation-level admin email reauthentication is removed from routes and UI`
- `a lookup does not reset the answer clock: the same 70 s signal bounds the whole answer phase`
- `the answer clock cutting the answer step mid-sentence is truncated, recorded and not charged`
- `service and restricted admin database identities stay separated`
- `staging backups are encrypted, atomic, private, and retain the newest three`
- `rejects destructive backup directory aliases and symlink components before mutation`
- `rejects unsafe writable backup parents before creating the target`
- `rejects a direct canonical sticky shared backup directory before chmod`
- `creates every absent backup path component privately despite a permissive caller umask`
- `same-second backups publish once without overwriting the completed archive`
- `find enumeration failures preserve existing backups and do not report completion`
- `refuses full disks and removes a failed-pipeline partial file`
- `database env validator accepts punctuated literal required secrets`
- `database env validator enforces an explicit staging owner uid without printing values`
- `database env validator accepts a private valid file without printing values`
- `personal report job migration is atomic, lease-bound, recoverable, and owner read-only`
- `personal report sections enforce owner-read RLS and service-owned durable transitions`
- `staging model provider env preparation removes legacy settings and keeps one stable key`
- `staging env validator rejects selector drift, duplicates, and unsafe permissions`
- `production env validators accept only self-hosted production selectors and role URLs`
- `immutable Skill migration enforces RPC-only identity, legacy adoption, and cascade-safe receipts`
- `importing the registry creates no runtime directory`
- `a used runtime root is removed when the process exits`
- `changed staging workflows are syntactically valid YAML`
- `live staging sync preserves env, state, incoming files, and encrypted backups`
- `live staging sync repairs nested deploy-tree drift without preserving foreign ownership`
- `first immutable deployment rolls back to validated local image IDs`
- `deployment scripts pass shell syntax validation`
- `is-docs-only-range.sh decides from local history and refuses non-ancestor ranges`
- `bounded npm install distinguishes forced timeout, OOM, and unknown failure and cleans its container`
</details>
@@ -0,0 +1,131 @@
# TASK:拦截检查要查分盘宫位的起算点(#183,BUG-1826,2026-11-11)
- Gitea:#183
- 分支:`codex/d9-reference-guard-20261111`(基于 staging `1505645287c59d53bc0c608c2c10b2da32d61f6a`,已含 #182)
- 前置:#182(格局状态词)已于 2026-11-11 合入 staging(产品负责人选 A:采用 Claude 原型 `855cc71c`,见 #182 评论)。本单**在同一个拦截检查上扩展**:`frontend/src/lib/consultation-answer-fact-guard.ts` 里 #182 加的格局状态检查保持原样,本单只在旁边新增一个检查。
- Bug 编号:BUG-1826(1809–1830 已为这一批预留;开工时核对 `docs/BUG_HISTORY.md` 末尾)
- 负责验收:Claude(jyotisha-2a)
- 依据:占星师 2026-11-09 第七轮回复 §5 P0.1
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
不得修改或删除既有断言来让测试通过;确需修改必须写原值/新值/原因并在 PROGRESS 列出。
## 现象(她已确认的一例)
婚恋题深度回答第 38 行:「D9 第 7 宫为巨蟹,宫内无星,宫主月亮在 D9 金牛入旺(从 D9 上升数第 11 宫)。」
- 卡上:`base.natal.d9.lagna = Capricorn`,`base.natal.d9.planets.Moon.d9_sign = Taurus`。
- 从 D9 上升(摩羯)数,金牛是第 **5** 宫;第 11 宫是从巨蟹(D9 第 7 宫)数的。句子写明了“从 D9 上升数”,所以是错的。
- 现有检查没拦住:句中的「从 … 数」命中 `BREAKER`,整句被跳过;而且现在没有任何“分盘宫位”检查。
她的要求原文:修复必须绑定 `chart_layer + entity + reference + relation + value`,不是只检查星座文本是否出现。
## 现状(staging `15056452` 实测,已含 #182)
- `frontend/src/lib/consultation-answer-fact-guard.ts`
- `factGuardCardView`:`view.vargas[layer] = { lagna, planets: {行星: 星座}, path }`;D9 来自 `base.natal.d9`,其他分盘来自 `domain_sections.<领域>.vargas`;各领域给出的同一分盘不一致时,这个分盘整个不进视图。
- `layerSpans` / `spanAt`:句子里显式写出的层(D9 / 婚姻分盘 / D10 …)管到哪里。
- `BREAKER`(约 :80):含 `起算|起第|起数|从…起/数|月亮起|月亮盘…`,命中后各检查跳过该句。
- 已有检查:分盘行星星座(`varga_sign`)、分盘上升(`varga_lagna`)、分盘宫主(`varga_house_lord`)、D1 的星座/宫位/上升/宫主、Karakamsha、Arudha;**没有分盘宫位**。
- `push` 会按“同实体、文本重叠”合并,不符优先。
- #182 已加(**只读,不改**):`FactGuardKind` 里的 `yoga_status`、`CardView.yogas` 与 `yogaCardEntries`、`normalizeYogaName`、`yogaNamePattern`、`splitProse`(约 :357)、`yogaSentences`(约 :375)、`yogaWordClass`、`checkYogaStatus`(约 :430,在 `checkKarakamsha` 开头调用)、重写提示里的「格局状态」一条。
- `checkLine`(约 :469)末尾依次调用 `checkKarakamsha`、`checkArudha`。
## 要做的
只**新增**一个检查,不改已有检查的任何正则、`BREAKER`、`layerSpans` 和判定(包括 #182 的格局状态检查)。新检查的入口单独写(例如 `checkVargaHouse(line, offset, view, push)`),在 `checkLine` 末尾 `checkArudha(...)` 之后调用。
### 1. 新的事实种类 `varga_house`
加进 `FactGuardKind`。一条记录绑定五样:
| 字段 | 含义 | 本例 |
|---|---|---|
| `layer` | 分盘(D2…D60,不含 D1) | `D9` |
| `entity` | 行星 | `Moon` |
| `reference` | 起算点,写成 `from D9 lagna (Capricorn)`、`from sign Cancer` 或 `from D9 Venus (Aquarius)` | `from D9 lagna (Capricorn)` |
| 关系 | 整宫制,含起点计数:`((实体星座序号 − 起点星座序号 + 12) % 12) + 1` | — |
| `claimed` / `card` | 句中的宫数 / 按卡算出的宫数 | `11 宫` / `5 宫(从摩羯数;第 11 宫是从巨蟹数)` |
`fact_id` 写 `<layer>:<entity>@<星座>/from:<起点星座>`,`source_path` 写实体星座的卡上路径(D9 是 `base.natal.d9.planets.<行星>.d9_sign`,其他分盘是 `<varga.path>.planets.<行星>`)。
### 2. 只查“写全了”的句子(高精度子集)
一句(正文按 `。!?;` 与换行断句,括号内不断;表格行先按 `|` 分格,格内同样只按 `。!?;` 断句,不跨格、不跨句沿用行星名或起算点——与 #182 验收采用的分句口径一致;可以调用 `splitProse(line, base, /[。!?;\n]/u)`,但不得改它的默认参数或实现)只有同时满足下面四条才检查,否则不检查(也不记 `not_on_card`):
1. **层**:句子在某个显式分盘的范围里(`spanAt` 给出的层不是 `D1`),且视图里有这一层。
2. **实体**:同一句里,宫数之前最近的一个行星名,且这颗行星在这一层的星座在句中写出、并与卡一致(不一致的已由 `varga_sign` 报告,这里不重复检查);或句中没写星座、但卡上这一层有这颗行星。
3. **起算点**:句中明确写出,且属于下面三种之一:
- 这一层的上升:`从 D9 上升数`、`从 D9 上升起`、`D9 上升起算`、`从 D9 Lagna 数` 以及去掉“D9”、但在 D9 范围内的同样写法(`从上升数`);视图里这一层要有 `lagna`;
- 一个星座:`从巨蟹数`、`从巨蟹座起`、`以巨蟹为第 1 宫`;
- 这一层里的一颗行星:`从金星数`、`从 D9 金星起`;视图里这一层要有这颗行星。
- 其他起算点(Karakamsha、AL / UL、月亮盘、D1 的宫、行运、年盘等)一律不检查。
4. **值**:`第 N 宫`(`N` 为 1–12),紧跟在起算点写法之后,或与起算点在同一对括号里。
`BREAKER` 对本检查不起作用(本检查自己识别起算点);但句中若还有 `行运|过境|Transit|流年|年盘|Varsha|Tajika|KP|宫头|Chalit` 这类词,仍不检查。
### 3. 判定与输出
- 按第 1 节的关系算出宫数,等于句中值 → `push(…, "match")`;不等 → 不符。
- 不符时 `card` 文字:先写按句中起算点算出的宫数;若句中的数恰好等于从“这一层上升”或“这一层某个宫的星座”数出来的值,补一句是从哪里数的(本例:`5 宫(从摩羯数;第 11 宫是从巨蟹数)`)。只补一处,找不到就不补。
- `consultationFactRepairPrompt` 的“分清层与实体”那一条后面(即 #182 加的“格局状态”那一条之前)加一条(逐字照写):`- 分盘宫位:从哪里起算就按哪里数,写明起算点;从上升数和从某一宫数是两件事,不能互相照抄。` 其余提示不改。
## 测试
新建 `frontend/tests/consult-varga-house-reference-1826.test.ts`,用**本文件内新建的合成卡**(不写真人资料、不改 `tests/fixtures/fact-guard-minimal-card.ts`):
1. **她的最小反例**:D9 `lagna: "Capricorn"`、`Moon.d9_sign: "Taurus"`;答案用原句「D9 第 7 宫为巨蟹,宫内无星,宫主月亮在 D9 金牛入旺(从 D9 上升数第 11 宫)。」→ 恰好 1 条 `varga_house` 不符:`layer: "D9"`、`entity: "Moon"`、`reference` 含 `lagna`、`claimed: "11 宫"`、`card` 以 `5 宫` 开头并含 `巨蟹`。
2. **同值不同体系(负例)**:同一张卡,
- 「宫主月亮在 D9 金牛(从巨蟹数第 11 宫)。」→ 0 条不符(起算点是巨蟹,11 是对的),1 条 `match`;
- 「宫主月亮在 D9 金牛(从 D9 上升数第 5 宫)。」→ 0 条不符;
- D1 句「本命月亮在第 11 宫」,卡上 D1 月亮在 11 宫 → 本检查不产生任何 `varga_house` 记录;
- 没写起算点的「月亮在 D9 第 11 宫」→ 本检查不检查(0 条 `varga_house`)。
3. **其他起算点**:从 D9 金星数(卡上 D9 金星在水瓶,月亮金牛 → 第 4 宫)写成第 4 宫相符、写成第 5 宫不符;从 Karakamsha / 月亮盘 / AL 起算 → 不检查。
4. **其他分盘**:D10 一例(上升、某行星、写错宫数)不符;该分盘在两个领域段里不一致(视图里没有)→ 不检查。
5. **排除词**:句中有「行运」「年盘」「KP」→ 不检查。
6. **已有检查不变**(不改其中任何断言):`frontend/tests/consult-answer-fact-guard-1789.test.ts` 与 #182 的 `consult-yoga-status-wording-1825.test.ts` 全部照样通过;在新测试里再加一条:她的原句中「D9 金牛」这一处的 `varga_sign` 记录(相符)仍然存在。
7. **重写**:第 1 条的单元,替换句仍写「从 D9 上升数第 11 宫」→ `factRepairRejection` 返回 `still_mismatched`;改成「从 D9 上升数第 5 宫」→ `null`。
## 允许改的文件(只有这些)
- 新建:`frontend/tests/consult-varga-house-reference-1826.test.ts`、`docs/tasks/PROGRESS-d9-reference-guard-20261111.md`
- 修改:`frontend/src/lib/consultation-answer-fact-guard.ts`(只加第 1–3 节的内容和 repair 提示一行)、`docs/BUG_HISTORY.md`(只在末尾追加 BUG-1826,不得用 sed 改整个文件)
## 不要碰
- `references/**`(占星师的方法文件),包括 `references/product_runtime_contract.md`:本单不改合同文字。
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/index.ts`、`frontend/src/mastra/product-voice.ts`。
- `frontend/src/lib/yoga-status-words.ts`,以及 #182 加进 fact guard 的格局状态检查:`yoga_status`、`CardView.yogas`、`yogaCardEntries`、`normalizeYogaName`、`yogaNamePattern`、`splitProse`、`yogaSentences`、`yogaWordClass`、`checkYogaStatus`、`checkKarakamsha` 开头那次调用、重写提示里的「格局状态」一条。
- 既有测试文件:`frontend/tests/consult-answer-fact-guard-1789.test.ts`、`consult-fact-guard-stream-1789.test.ts`、`consult-yoga-status-wording-1825.test.ts` 一行不改。
- `frontend/src/lib/stream-agent-response.ts`、`frontend/src/lib/consultation-evidence-card.ts`、`frontend/tests/fixtures/fact-guard-minimal-card.ts`。
- 冻结文件:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`;本单不改任何 `scripts/**`。
- 进行中单子的地盘:#177 / #178(`scripts/ondemand_calc/**` 与注册表)、#172(`frontend/src/mastra/consultation-tools.ts`、`frontend/src/app/api/consult/route.ts`);已验收的 #184 / #185 / #186 的文件本单同样不碰。
## 验收标准
1. 她的原句被拦下,记录五样齐全(层、实体、起算点、关系对应的宫数、句中值);`card` 指出第 11 宫是从巨蟹数的。
2. 同值不同体系的负例全部不报(第 2 条),没写起算点的不检查。
3. 已有检查的结果不变:`consult-answer-fact-guard-1789` 与 `consult-yoga-status-wording-1825` 全过,不改任何既有断言。
4. 只出现允许清单里的改动。
5. **精度**:Claude 验收时用第七轮验收包 10 份回答与第八轮盲测回答跑一遍,人工核对每一条 `varga_house`(不符与相符都看),报告精度;PROGRESS 里请列出测试覆盖的句式和“已知未覆盖”的句式。
## 自测命令(Windows / PowerShell)
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
- 在 `frontend` 下运行 `npm run lint`
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/consult-varga-house-reference-1826.test.ts tests/consult-answer-fact-guard-1789.test.ts tests/consult-fact-guard-stream-1789.test.ts tests/consult-yoga-status-wording-1825.test.ts`
- 再在 `frontend` 下跑一次 `npm test`,把失败的测试名写进 PROGRESS。
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时再跑。
## 进度记录
`docs/tasks/PROGRESS-d9-reference-guard-20261111.md`
## 何时停下来选“需拍板”
- 要改 `BREAKER`、`layerSpans`、#182 的格局状态检查或任何已有检查才能做到时。
- 要支持第 2 节第 3 条以外的起算点时。
- 句中宫数的写法与第 2 节第 4 条不同、又想纳入检查时。
- 任务书没写到的任何文案或取舍。
@@ -80,7 +80,7 @@ const TIME_CONTEXT = /\d{4}\s*年|\d{4}-\d{1,2}|\d{1,2}\s*月|现在|目前|眼
const BREAKER = /行运|过境|Gochara|[Tt]ransit|流年|年盘|Varsha|Tajika|Muntha|年主|KP|Placidus|宫头|起算|起第|起数|为第\s*1\s*宫|作为第?一宫|从\s*[^\s,。;]{0,10}\s*(起|数)|月亮起|月亮盘|Chandra|Sudar[sś]han|Chalit|D1\s*投射|投到/gu;
export type FactGuardKind = "varga_sign" | "varga_lagna" | "varga_house_lord" | "d1_sign" | "d1_house" | "d1_lagna" | "d1_house_lord"
| "karakamsha_sign" | "karakamsha_house" | "karakamsha_lord" | "karakamsha_lord_house" | "arudha_sign" | "arudha_lord" | "arudha_house" | "arudha_lord_house" | "yoga_status";
| "karakamsha_sign" | "karakamsha_house" | "karakamsha_lord" | "karakamsha_lord_house" | "arudha_sign" | "arudha_lord" | "arudha_house" | "arudha_lord_house" | "yoga_status" | "varga_house";
export type AnswerFactClaim = AnswerFactMismatch & { status: "match" | "mismatch" | "not_on_card" };
type Emit = (item: AnswerFactMismatch, status?: AnswerFactClaim["status"]) => void;
@@ -588,6 +588,149 @@ function checkLine(line: string, offset: number, view: CardView, push: Emit) {
checkKarakamsha(line, offset, view, push, brokenBetween);
checkArudha(line, offset, view, push, brokenBetween);
checkVargaHouse(line, offset, spans, view, push);
}
const VARGA_REFERENCE = new RegExp([
`从\\s*(?:(?<fromLayer>D\\d{1,2})\\s*)?(?:(?<lagna>上升|Lagna)|(?<sign>${S})座?|(?<planet>${P}))\\s*(?:起算|起数|数|起)`,
"(?<![\\p{L}\\p{N}])(?:(?<lagnaLayer>D\\d{1,2})\\s*)?(?<lagnaOnly>上升|Lagna)\\s*起算",
`以\\s*(?<firstSign>${S})座?\\s*为\\s*第\\s*1\\s*宫`,
].join("|"), "gu");
const VARGA_HOUSE_NUMBER = /第\s*(1[0-2]|[1-9])\s*宫/gu;
const VARGA_HOUSE_EXCLUDED = /行运|过境|Gochara|Transit|流年|年盘|Varsha|Tajika|Muntha|年主|KP|Placidus|宫头|Chalit|月亮盘|Chandra|Sudar[sś]han/iu;
const VARGA_HOUSE_BREAKS = /[。!?;\n]/u;
const VARGA_LAYER = /^D(?:[2-9]|[1-5]\d|60)$/u;
const VARGA_PLACEMENT_PREFIX = new RegExp(`^\\s*[((]?\\s*${PLACED}`, "u");
const VARGA_PLACEMENT_SIGN = new RegExp(`^\\s*(${S})座?`, "u");
const VARGA_SIGN_MENTION = new RegExp(S, "u");
const VARGA_PLANET_MENTION = new RegExp(P, "gu");
const VARGA_AMBIGUOUS_SIGN = /^\s*(?:或|或者|\/|~|~|到|至|宫?\s*的|对应|所对应|对宫|方向)/u;
function enclosingParenthesis(text: string, at: number): number | undefined {
const open: number[] = [];
for (let i = 0; i < at; i += 1) {
if (text[i] === "(" || text[i] === "(") open.push(i);
else if (text[i] === ")" || text[i] === ")") open.pop();
}
return open.at(-1);
}
function wholeSignHouse(sign: Sign, reference: Sign): number {
return (FACT_GUARD_SIGNS.indexOf(sign) - FACT_GUARD_SIGNS.indexOf(reference) + 12) % 12 + 1;
}
/** BUG-1826: only an explicit varga, entity, counting point and house form a claim. */
function checkVargaHouse(line: string, offset: number, spans: readonly Span[], view: CardView, push: Emit) {
const entityBoundary = /[;;::]|——/u;
const cells = line.trimStart().startsWith("|") ? line.split("|") : [line];
let cellOffset = 0;
for (const cell of cells) {
const sentences = splitProse(cell, cellOffset, VARGA_HOUSE_BREAKS);
cellOffset += cell.length + 1;
for (const scope of sentences) {
const sentence = line.slice(scope.start, scope.end);
if (VARGA_HOUSE_EXCLUDED.test(sentence)) continue;
const references = [...sentence.matchAll(VARGA_REFERENCE)].filter((hit) =>
!hit.groups?.lagnaOnly || !/(?:从|以)[^,,。!?;(())]*$/u.test(sentence.slice(0, hit.index)),
);
if (!references.length) continue;
const absolute = offset + scope.start;
const planets = [...sentence.matchAll(VARGA_PLANET_MENTION)].filter((planet) =>
!references.some((hit) => hit.index <= planet.index && planet.index < hit.index + hit[0].length),
);
const houses = [...sentence.matchAll(VARGA_HOUSE_NUMBER)].filter((house) =>
!references.some((hit) => hit.index <= house.index && house.index < hit.index + hit[0].length),
);
const bindings = references.map((reference) => {
const end = reference.index + reference[0].length;
const adjacent = houses.find((house) => end <= house.index && /^[\s,,::]*$/u.test(sentence.slice(end, house.index)));
if (adjacent) return { reference, house: adjacent };
const bracket = enclosingParenthesis(sentence, reference.index);
const nearby = bracket === undefined ? [] : houses.filter((house) => enclosingParenthesis(sentence, house.index) === bracket);
return { reference, house: nearby.length === 1 ? nearby[0] : undefined };
});
for (const house of houses) {
const houseEnd = house.index + house[0].length;
if (/^\s*(?:的\s*)?(?:宫主|主星|之主|主)/u.test(sentence.slice(houseEnd))) continue;
const compatible = bindings.filter((binding) => binding.house === house);
if (compatible.length !== 1) continue;
const reference = compatible[0]!.reference;
const groups = reference.groups!;
const namedLayer = groups.fromLayer ?? groups.lagnaLayer;
const referenceLayerAt = namedLayer ? reference.index + reference[0].indexOf(namedLayer) : undefined;
const start = absolute + house.index;
const end = absolute + houseEnd;
const span = spanAt(spans, start, end) ?? (referenceLayerAt === undefined ? undefined
: spanAt(spans, absolute + referenceLayerAt, absolute + referenceLayerAt + namedLayer!.length));
const layer = span?.layer;
if (!layer || !VARGA_LAYER.test(layer) || (namedLayer && namedLayer !== layer)) continue;
const varga = view.vargas[layer];
const following = planets.find((planet) => planet.index >= houseEnd
&& /^\s*(?:(?:里的|内的|中的|的)\s*)?$/u.test(sentence.slice(houseEnd, planet.index)));
const entityHit = following ?? planets.findLast((planet) => planet.index < house.index
&& !entityBoundary.test(sentence.slice(planet.index + planet[0].length, house.index)));
if (!varga || !entityHit) continue;
const entity = PLANET_NAMES[entityHit[0]]!;
const sign = varga.planets[entity];
if (!sign) continue;
const placementStart = entityHit.index + entityHit[0].length;
const placementEnd = reference.index >= placementStart ? Math.min(reference.index, house.index) : house.index;
const tail = following
? sentence.slice(placementStart, references.find((hit) => hit.index >= placementStart)?.index)
.split(/[;;]|——/u, 1)[0]!
: sentence.slice(placementStart, placementEnd);
const prefix = VARGA_PLACEMENT_PREFIX.exec(tail)?.[0].length ?? 0;
const placement = tail.slice(prefix);
const layerToken = [...placement.matchAll(LAYER_TOKEN)].find((hit) => hit.index === 0);
const explicitPlacementLayer = layerToken ? LAYER_ALIAS(layerToken) : undefined;
const entityLayer = explicitPlacementLayer
?? spanAt(spans, absolute + entityHit.index, absolute + placementStart)?.layer;
if (entityLayer && entityLayer !== layer) continue;
const signText = placement.slice(layerToken?.[0].length ?? 0);
const written = VARGA_PLACEMENT_SIGN.exec(signText);
if (written) {
const remainder = signText.slice(written[0].length);
if (SIGN_NAMES[written[1]!] !== sign || SIGN_THEN_LORD.test(remainder) || VARGA_AMBIGUOUS_SIGN.test(remainder)) continue;
}
if (!written && VARGA_SIGN_MENTION.test(signText)) continue;
const planetReference = groups.planet ? PLANET_NAMES[groups.planet] : undefined;
const signReference = groups.sign ?? groups.firstSign;
const referenceSign = signReference ? SIGN_NAMES[signReference]
: planetReference ? varga.planets[planetReference] : varga.lagna;
if (!referenceSign) continue;
const point = signReference ? `from sign ${referenceSign}`
: `from ${layer} ${planetReference ?? "lagna"} (${referenceSign})`;
const claimed = Number(house[1]);
const actual = wholeSignHouse(sign, referenceSign);
const alternative = FACT_GUARD_SIGNS[(FACT_GUARD_SIGNS.indexOf(sign) - claimed + 13) % 12]!;
const card = claimed !== actual && varga.lagna
? `${actual} 宫(从${SIGN_ZH[referenceSign]}数;第 ${claimed} 宫是从${SIGN_ZH[alternative]}数)`
: `${actual} 宫`;
const source_path = `${varga.path}.planets.${entity}${varga.path === "base.natal.d9" ? ".d9_sign" : ""}`;
// The legacy placement matcher cannot read 「月亮在 D9 金牛」. When the new
// rule verifies that inline sign, record it separately from the house claim.
if (explicitPlacementLayer && written) {
const signEnd = placementStart + prefix + (layerToken?.[0].length ?? 0) + written[0].length;
push({
kind: "varga_sign", layer, entity, reference: "sign", claimed: SIGN_ZH[sign], card: SIGN_ZH[sign],
fact_id: `${layer}:${entity}@${sign}`, source_path,
quote: sentence.slice(entityHit.index, signEnd), start: absolute + entityHit.index, end: absolute + signEnd,
}, "match");
}
// When the reference follows the house, the legacy sign quote may
// already include the number. Anchor the new claim at the reference.
const anchor = house.index < reference.index ? reference : house;
const anchorStart = absolute + anchor.index;
push({
kind: "varga_house", layer, entity, reference: point, claimed: `${claimed} 宫`, card,
fact_id: `${layer}:${entity}@${sign}/from:${referenceSign}`, source_path,
quote: anchor[0], start: anchorStart, end: anchorStart + anchor[0].length,
}, claimed === actual ? "match" : "mismatch");
}
}
}
}
function checkKarakamsha(line: string, offset: number, view: CardView, push: Emit, brokenBetween: (from: number, to: number) => boolean) {
@@ -754,6 +897,7 @@ export function consultationFactRepairPrompt(units: readonly FactGuardUnit[]): s
"下面这些句子引用的盘面事实与本轮证据卡的层、实体或参照不符,已被拒收。逐条重写这一句(表格里的只重写这一格):",
"- 只改与卡不符的部分,换成下面给出的卡上值;其余内容、语气和判断保持不变;如果改了值以后原来的推论不成立,就删掉那半句推论,不另找理由。",
"- 分清层与实体:婚姻分盘(D9)里的木星、Karakamsha 星座所在的宫、Karakamsha 主星的本命落宫,是三件不同的事,不能互相照抄。",
"- 分盘宫位:从哪里起算就按哪里数,写明起算点;从上升数和从某一宫数是两件事,不能互相照抄。",
"- 格局状态:照卡上给出的状态文字改写,不写「成立」「不成立」「非成立」「落空」,除非卡上的状态文字本身就是这个意思。",
"- 不新增计算、技法或执行状态:不写 executed / blocked / 已执行 / 已核验一类的词,不加审计表行,不换行,不写「|」。",
"- 只输出 JSON 数组:[{\"id\": 1, \"text\": \"重写后的整句\"}],不要别的文字。",
@@ -0,0 +1,208 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
consultationFactRepairPrompt,
factGuardUnits,
factRepairRejection,
findAnswerFactClaims,
findAnswerFactMismatches,
} from "../src/lib/consultation-answer-fact-guard.ts";
import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts";
function syntheticCard(conflictingD10 = false): EvidenceCard {
return {
base: {
natal: {
planets: { Moon: { sign: "Aquarius", house: 11 } },
ascendant: { sign: "Aries" },
d9: {
lagna: "Capricorn",
planets: { Moon: { d9_sign: "Taurus" }, Venus: { d9_sign: "Aquarius" } },
},
},
},
domain_sections: {
career: { vargas: { D10: { lagna: "Aries", planets: { Mercury: "Gemini" } } } },
...(conflictingD10 ? { general: { vargas: { D10: { lagna: "Aries", planets: { Mercury: "Cancer" } } } } } : {}),
},
} as unknown as EvidenceCard;
}
const card = syntheticCard();
const original = "D9 第 7 宫为巨蟹,宫内无星,宫主月亮在 D9 金牛入旺(从 D9 上升数第 11 宫)。";
const claims = (text: string, evidence = card) =>
findAnswerFactClaims(text, evidence).filter((claim) => claim.kind === "varga_house");
test("the reported D9 sentence identifies the five house-claim parts and the alternative counting point", () => {
const matches = claims(original);
assert.equal(matches.length, 1);
const [claim] = matches;
assert.equal(claim?.status, "mismatch");
assert.equal(claim?.layer, "D9");
assert.equal(claim?.entity, "Moon");
assert.equal(claim?.reference, "from D9 lagna (Capricorn)");
assert.equal(claim?.claimed, "11 宫");
assert.equal(claim?.card, "5 宫(从摩羯数;第 11 宫是从巨蟹数)");
assert.equal(claim?.fact_id, "D9:Moon@Taurus/from:Capricorn");
assert.equal(claim?.source_path, "base.natal.d9.planets.Moon.d9_sign");
assert.equal(original.slice(claim?.start, claim?.end), "第 11 宫");
const sign = findAnswerFactClaims(original, card).find((claim) => claim.kind === "varga_sign");
assert.equal(sign?.status, "match");
assert.equal(sign?.card, "金牛");
assert.ok(findAnswerFactClaims("D9:月亮在金牛。", card).some((claim) =>
claim.kind === "varga_sign" && claim.status === "match"));
});
test("the same placement gives different correct houses under explicit lagna and sign references", () => {
for (const [text, reference, value] of [
["宫主月亮在 D9 金牛(从巨蟹数第 11 宫)。", "from sign Cancer", "11 宫"],
["宫主月亮在 D9 金牛(从巨蟹座起第 11 宫)。", "from sign Cancer", "11 宫"],
["宫主月亮在 D9 金牛(以巨蟹为第 1 宫,第 11 宫)。", "from sign Cancer", "11 宫"],
["宫主月亮在 D9 金牛(从 D9 上升数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["宫主月亮在 D9 金牛(从 D9 上升起第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["宫主月亮在 D9 金牛(从 D9 Lagna 数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["宫主月亮在 D9 金牛(从上升数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
["D9:月亮在金牛(第 5 宫,D9 上升起算)。", "from D9 lagna (Capricorn)", "5 宫"],
["D9:月亮(从上升数第 5 宫)。", "from D9 lagna (Capricorn)", "5 宫"],
]) {
assert.deepEqual(claims(text!).map((claim) => [claim.status, claim.reference, claim.claimed]),
[["match", reference, value]], text);
}
});
test("a reference planet is excluded from the entity search", () => {
for (const [point, number, status] of [
["从 D9 金星起", 4, "match"], ["从金星数", 5, "mismatch"],
] as const) {
const text = `宫主月亮在 D9 金牛(${point}第 ${number} 宫)。`;
const out = claims(text);
assert.equal(out.length, 1);
assert.equal(out[0]?.entity, "Moon");
assert.equal(out[0]?.reference, "from D9 Venus (Aquarius)");
assert.equal(out[0]?.status, status);
assert.equal(out[0]?.fact_id, "D9:Moon@Taurus/from:Aquarius");
}
});
test("unsupported, unbound and incomplete house claims are left alone", () => {
for (const text of [
"本命月亮在第 11 宫。",
"月亮在 D9 第 11 宫。",
"D9 月亮在金牛(从 Karakamsha 数第 11 宫)。",
"D9 月亮在金牛(从月亮盘数第 11 宫)。",
"D9 月亮在金牛(从月亮盘 上升起算第 11 宫)。",
"D9 月亮在金牛(从 AL 数第 11 宫)。",
"D9 月亮在金牛(从 D1 上升数第 11 宫)。",
"本命月亮在金牛(从 D9 上升数第 11 宫)。",
"D9 月亮在金牛(从上升数第 13 宫)。",
"D9 月亮在金牛(从火星数第 11 宫)。",
"D9 火星(从上升数第 11 宫)。",
"D7 月亮(从上升数第 11 宫)。",
"月亮在金牛(从上升数第 11 宫)。",
"D9 月亮在金牛第 11 宫(从上升数)。",
"D9:月亮在金牛或双子(从上升数第 11 宫)。",
"D9:月亮在金牛(第 5 宫或第 11 宫,从上升数)。",
]) assert.deepEqual(claims(text), [], text);
assert.equal(claims("D9:月亮在金牛(从上升数第 5 宫,第 11 宫是另一处)。").length, 1);
});
test("an explicitly wrong sign still belongs to the existing sign check", () => {
const text = "D9:月亮在双子(从上升数第 11 宫)。";
assert.deepEqual(claims(text), []);
assert.ok(findAnswerFactMismatches(text, card).some((claim) =>
claim.kind === "varga_sign" && claim.claimed === "双子"));
});
test("other available vargas are checked, and conflicting evidence for a layer is ignored", () => {
const text = "D10:水星在双子(从上升数第 4 宫)。";
const [claim] = claims(text);
assert.equal(claim?.status, "mismatch");
assert.equal(claim?.layer, "D10");
assert.equal(claim?.source_path, "domain_sections.career.vargas.D10.planets.Mercury");
assert.equal(claim?.card.startsWith("3 宫"), true);
assert.deepEqual(claims(text, syntheticCard(true)), []);
});
test("transit, annual and KP wording prevents a natal varga check in that sentence", () => {
for (const word of ["行运", "过境", "Transit", "流年", "年盘", "Varsha", "Tajika", "KP", "宫头", "Chalit"]) {
assert.deepEqual(claims(`D9:月亮在金牛(从上升数第 11 宫),这是${word}。`), [], word);
}
});
test("the new check keeps commas inside a sentence and never carries an entity or reference across cells", () => {
const checked = "| D9 | 月亮在金牛,位置写作(从上升数第 5 宫) |";
assert.equal(claims(checked)[0]?.status, "match");
for (const text of [
"| D9 | 月亮在金牛 | 从上升数第 5 宫 |",
"| D9 | 月亮在金牛,从上升数 | 第 5 宫 |",
"| D9 | 月亮在金牛;从上升数第 5 宫 |",
"D9:月亮在金牛。从上升数第 5 宫。",
"D9:月亮在金牛\n从上升数第 5 宫。",
]) assert.deepEqual(claims(text), [], text);
});
test("the repair path rejects the same wrong count and accepts the corrected sentence", () => {
const mismatches = findAnswerFactMismatches(original, card);
const [unit] = factGuardUnits(original, mismatches);
assert.ok(unit);
assert.equal(factRepairRejection(unit, original, card), "still_mismatched");
assert.equal(factRepairRejection(unit, original.replace("第 11 宫", "第 5 宫"), card), null);
assert.match(consultationFactRepairPrompt([unit]), /分盘宫位:从哪里起算就按哪里数/);
});
function houseBindingCard(lagna = "Scorpio"): EvidenceCard {
return {
base: {
natal: {
d9: {
lagna,
planets: {
Mercury: { d9_sign: "Sagittarius" },
Venus: { d9_sign: "Pisces" },
Saturn: { d9_sign: "Aries" },
},
},
},
},
domain_sections: { general: { vargas: { D6: { lagna: "Pisces", planets: { Sun: "Aquarius" } } } } },
} as unknown as EvidenceCard;
}
test("a planet immediately after the house number is the entity for correct and incorrect counts", () => {
const evidence = houseBindingCard();
const correct = "11 宫主金星同时是功能凶星;D9 从上升数第 2 宫的水星入敌。";
const wrong = "D9 从上升数第 5 宫的水星入敌。";
assert.deepEqual(claims(correct, evidence).map(({ status, entity }) => [status, entity]), [["match", "Mercury"]]);
assert.deepEqual(claims(wrong, evidence).map(({ status, entity }) => [status, entity]), [["mismatch", "Mercury"]]);
for (const connector of ["", " 的 ", "里的", "内的", "中的"]) {
const text = `D9 金星在双鱼,从上升数第 2 宫${connector}水星入敌。`;
assert.deepEqual(claims(text, evidence).map(({ status, entity }) => [status, entity]), [["match", "Mercury"]], text);
}
});
test("a sign written after the house number and planet must agree with the evidence", () => {
const evidence = houseBindingCard();
assert.equal(claims("D9 从上升数第 2 宫里的水星在射手。", evidence)[0]?.status, "match");
assert.deepEqual(claims("D9 金星在双鱼,从上升数第 2 宫的水星在白羊。", evidence), []);
assert.deepEqual(claims("D9 从上升数第 5 宫的水星:白羊。", evidence), []);
});
test("house-lord wording does not claim a planet placement", () => {
const evidence = houseBindingCard("Cancer");
for (const ending of ["的宫主", "之主", "主星", " 的 主", "宫主"]) {
const text = `D9 里第 7 宫为摩羯,主星土星落在白羊……从 D9 上升数第 7 宫${ending}本身需要取消落陷。`;
assert.deepEqual(claims(text, evidence), [], text);
}
});
test("backward entity binding stops at semicolons, dashes and colons, including inside parentheses", () => {
const evidence = houseBindingCard();
assert.deepEqual(claims("金星在 D9 双鱼;从上升数第 2 宫。", evidence), []);
for (const separator of [";", ";", "——", ":", ":"]) {
const text = `D9(11 宫主金星${separator}从上升数第 2 宫)。`;
assert.deepEqual(claims(text, evidence), [], text);
}
assert.equal(claims("D6 太阳水瓶(从 D6 上升数第 12 宫)。", evidence)[0]?.status, "match");
});